I'm trying to create a reverse halftone effect using Three.js, specifically with Threlte. This is my reference image:
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:
I'm working with a designer on this, and I have a texture I could utilize if necessary, although the quality dips when I do. Overall, I like the placement, of the dots 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:
Particles.svelte
<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!
Got a perfect implementation from Paul West on the Three.js community forum.
https://codepen.io/prisoner849/full/XJJEYLa
I’m implementing it like this:
<script>
import { T, useTask } from '@threlte/core';
import { OrbitControls } from '@threlte/extras';
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);
float n = snoise(vec3(cID * 0.05, time * 0.1));
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) * 1.9);
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);
vec4 diffuseColor = vec4( col, opacity );`
);
};
material.defines = { USE_UV: '' };
material.blendAlpha = THREE.AdditiveBlending;
useTask((delta) => {
gu.time.value += delta;
});
</script>
<T.PerspectiveCamera
position={[0, 0, 12]}
fov={25}
aspect={window.innerWidth / window.innerHeight}
near={0.1}
far={100}
makeDefault
>
<OrbitControls enableDamping />
</T.PerspectiveCamera>
<T.Mesh {geometry} {material} />