Creating a halftone texture (original) (raw)

I’m trying to create a reverse halftone effect using Threlte. This is my reference image:

Halftone effect Three.js

There are a couple of layers of dots, with some rather large areas of transparency, and colored layers that are offset, with an Additive Blend Mode. I’ve built out a component that can generate the dots, the colors, and the offset in a really nice way, but the problem that I’m running into is that the dots themselves are what is revealing the color, rather than the material behind the dots being the thing that’s revealing the color:

Halftone effect Three.js (1)

I’m working with a designer on this, and I have a textural image I could utilize if necessary, although the quality dips when I do (texture is below).

Overall, I like the placement of the dots in my component using simplexNoise, the color is something I still need to tweak to match exactly, but overall I am looking to adjust my component to draw the dots, put the colored plane behind them, and then reveal the colors from that portion of what’s being drawn in the scene. Below is the current iteration of my component:

<script lang="ts">
    import { T, Canvas } from '@threlte/core';
    import type { InstancedMesh, PerspectiveCamera } from 'three';
    import { onMount } from 'svelte';
    import * as THREE from 'three';
    import { createNoise2D } from 'simplex-noise';

    const gridSize = 120;
    const dotSpacing = 0.25;
    const maxDotSize = 0.12;
    const minDotSize = 0;

    const stackCount = 3;
    const yOffset = 0.24;
    const stackColors = ['rgb(0,0,255)', 'rgb(0,255,0)', 'rgb(255,0,0)'];

    const instanceCount = gridSize * gridSize * stackCount;

    const simplex = createNoise2D();

    const positions: Array<[number, number, number]> = [];
    const scales: number[] = [];
    const colors: THREE.Color[] = [];

    for (let x = 0; x < gridSize; x++) {
        for (let y = 0; y < gridSize; y++) {
            const xNorm = x / gridSize;
            const yNorm = y / gridSize;

            // Use simplex noise for clustering
            const noise = simplex(xNorm * 4, yNorm * 4);
            const normalized = (noise + 1) / 2;
            const exponent = 3;
            const dotSize = minDotSize + (maxDotSize - minDotSize) * Math.pow(normalized, exponent);

            const xPos = (x - gridSize / 2) * dotSpacing;
            const yPos = (y - gridSize / 2) * dotSpacing;

            for (let i = 0; i < stackCount; i++) {
                positions.push([xPos, yPos + i * yOffset, 0]);
                scales.push(dotSize);
                colors.push(new THREE.Color(stackColors[i % stackColors.length]));
            }
        }
    }

    let camera: PerspectiveCamera;
    let instancedMesh: InstancedMesh;

    onMount(() => {
        if (!instancedMesh) return;
        const dummy = new THREE.Matrix4();
        for (let i = 0; i < instanceCount; i++) {
            const [x, y, z] = positions[i];
            const scale = scales[i];
            dummy.makeTranslation(x, y, z);
            dummy.scale(new THREE.Vector3(scale, scale, 1));
            instancedMesh.setMatrixAt(i, dummy);
            instancedMesh.setColorAt(i, colors[i]);
        }
        instancedMesh.instanceMatrix.needsUpdate = true;
        instancedMesh.instanceColor!.needsUpdate = true;
    });
</script>

<T.PerspectiveCamera bind:ref={camera} makeDefault position={[0, 0, 10]} fov={50} />

<T.InstancedMesh args={[null, null, instanceCount]} bind:ref={instancedMesh} frustumCulled={false}>
    <T.CircleGeometry args={[1, 25]} />
    <T.MeshBasicMaterial blending={THREE.AdditiveBlending} toneMapped={false} />
</T.InstancedMesh>

If you have an answer, it doesn’t need to be Svelte/Threlte specific, I can port it from any framework, or vanilla Three.js as necessary. Thanks in advance!

The reference image looks more like a chromatic aberration effect than a simple transparency to the background.
I.e. you distort the r, g, and b channels separately with your noise function, with slightly different offsets… but not sure.

image

Ah, maybe so! Any suggestions on how? Like I said, I have a texture I could load that is from the designer. Below are the original texture he used, and a rasterized version that became the basis of the image above.

Texture_Transparent_v01

SampleImage_Jesse_1

Usually implemented as a shader:


vec4 ChromaticAberration(sampler2D tex,vec4 clr, vec2 uv){

    float dis = distance(uv,vec2(0.5));
    
    clr.r += texture(tex,uv + (dis*0.005)).r;
    clr.g += texture(tex,uv).g;
    clr.b += texture(tex,uv - (dis*0.005)).b;
   
    return clr;
    
    
}


void mainImage( out vec4 fragColor, in vec2 fragCoord ){
    vec2 uv = fragCoord.xy / iResolution.xy;
    fragColor=vec4(0.,0.,0.,1.);
    fragColor = ChromaticAberration(iChannel0,fragColor, uv);
}

(stolen from shadertoy ^)

I’ve looked into the code:
you can cut out the whole font loading stuff (lines 8 - 23) to make it even more concise.

I thought I was the only one trying to discover the text in the endless layers of dots…

That’s the fun in live code environments like Codepen: you can tinker with the code and see if and how the result changes. I literally did cut out those lines and it didn’t make a difference.

Yeah, true :slight_smile:
But it’s just a template, for the case, if I suddenly need to generate a texture with a text.

@prisoner849 for some reason, in my implementation, using Threlte, the colors feel much less vibrant- I’m newish to all of this still, so wondering if anything jumps out at you in my implementation. I swapped the noise shader to use this one:

which was linked to in your shader as being faster, and I’ve made some other minor tweaks to the sizes/background transparency etc. to match my specific needs, but even with none of those tweaks it doesn’t look as vibrant.

<script lang="ts">
    import { T, useTask } from '@threlte/core';
    import * as THREE from 'three';
    import { noise } from './shaders';

    let gu = $state({
        time: {
            value: 0
        }
    });

    const geometry = new THREE.PlaneGeometry(10, 10);
    const material = new THREE.MeshBasicMaterial();

    material.onBeforeCompile = (shader) => {
        shader.uniforms.time = gu.time;
        shader.fragmentShader = `
            uniform float time;
            ${noise}
            
            float getValue(vec2 uv){
                vec2 cID = floor(uv);
                vec2 cUV = fract(uv);

                vec3 gradient, dg, dg2;
                float n = psrddnoise(vec3(cID * 0.05, time * 0.15), vec3(0.0), time * 0.5, gradient, dg, dg2);
                n = abs(n);

                float r = sqrt(2.) * (1. - n * 0.5);

                float fw = length(fwidth(uv));
                float fCircle = smoothstep(r, r + fw, length(cUV - 0.5) * 2.);
                return fCircle;
            }
            
            ${shader.fragmentShader}
        `.replace(
            `vec4 diffuseColor = vec4( diffuse, opacity );`,
            `
              vec3 col = diffuse;

              vec2 uv = (vUv - 0.5) * 50.;
              vec2 shift = vec2(0, 1.7);
              col.r = getValue(uv - shift);
              col.g = getValue(uv);
              col.b = getValue(uv + shift);

              float alpha = max(max(col.r, col.g), col.b);
              vec4 diffuseColor = vec4(col, alpha);
              `
        );
    };

    material.defines = { USE_UV: '' };
    material.transparent = true;

    useTask((delta) => {
        gu.time.value += delta;
    });
</script>

<T.PerspectiveCamera
    position={[0, 0, 12]}
    fov={25}
    aspect={window.innerWidth / window.innerHeight}
    near={0.1}
    far={1000}
    makeDefault
></T.PerspectiveCamera>

<T.Mesh {geometry} {material} />

Mine just looks much less vibrant.

Screenshot 2025-05-05 at 11.23.49 AM

Looks like something wrong with color space, but I’m not sure.

Should I use srgb-linear?

srgb-linear looks much better, but still not quite as bright. Thanks for helping at all, again I’m just diving into this tech!

Screenshot 2025-05-05 at 12.01.12 PM