// Toon Shading — Cel shading with inverted-hull outline
//
// MeshToonMaterial + a custom DataTexture gradient map gives clean
// discrete tone bands (posterized diffuse lighting).
// An inverted-hull outline mesh (BackSide + normal-offset vertex shader)
// adds the characteristic inked silhouette.
//
// The gradient texture is regenerated when the bands count changes.
// Shape is rebuilt when shape or bands change (rebuildOnChange).
import * as THREE from 'three';
export const PARAMS = {
shape: { value: "Torus Knot", options: ["Torus Knot", "Icosahedron", "Octahedron", "Torus"], label: "Shape", folder: "Structure", rebuildOnChange: true },
bands: { value: 4, min: 2, max: 6, step: 1, label: "Tone Bands", folder: "Appearance", rebuildOnChange: true },
outlineThick: { value: 0.04, min: 0, max: 0.15, step: 0.005, label: "Outline Thickness", folder: "Appearance" },
hue: { value: 200, min: 0, max: 360, step: 1, label: "Base Hue", folder: "Appearance" },
rotateSpeed: { value: 0.3, min: 0, max: 2, step: 0.01, label: "Rotation Speed", folder: "Behavior" },
};
function makeGradientMap(THREE, bands) {
const data = new Uint8Array(bands);
for (let i = 0; i < bands; i++) {
data[i] = Math.floor((i + 1) * 255 / bands);
}
const tex = new THREE.DataTexture(data, bands, 1, THREE.RedFormat);
tex.minFilter = THREE.NearestFilter;
tex.magFilter = THREE.NearestFilter;
tex.needsUpdate = true;
return tex;
}
function buildGeometry(THREE, shape) {
switch (shape) {
case "Icosahedron": return new THREE.IcosahedronGeometry(1.3, 1);
case "Octahedron": return new THREE.OctahedronGeometry(1.5, 1);
case "Torus": return new THREE.TorusGeometry(1.1, 0.4, 16, 48);
default: return new THREE.TorusKnotGeometry(1, 0.35, 128, 16);
}
}
function buildScene(THREE, scene, params) {
// Clear previous scene objects (except lights we'll re-add)
while (scene.children.length > 0) scene.remove(scene.children[0]);
scene.background = new THREE.Color(0xf8f8f8);
// Lighting — toon needs at least a directional light
const ambLight = new THREE.AmbientLight(0xffffff, 0.2);
scene.add(ambLight);
const dirLight = new THREE.DirectionalLight(0xffffff, 1.5);
dirLight.position.set(3, 5, 4);
scene.add(dirLight);
const geo = buildGeometry(THREE, params.shape);
const gradientMap = makeGradientMap(THREE, params.bands | 0);
const toonMat = new THREE.MeshToonMaterial({
color: new THREE.Color().setHSL(params.hue / 360, 0.6, 0.55),
gradientMap,
});
const mesh = new THREE.Mesh(geo, toonMat);
scene.add(mesh);
// Inverted hull outline
const outlineMat = new THREE.ShaderMaterial({
uniforms: {
u_thickness: { value: params.outlineThick },
u_color: { value: new THREE.Color(0x0a0a0a) },
},
vertexShader: `
uniform float u_thickness;
void main() {
vec3 pos = position + normal * u_thickness;
gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
}
`,
fragmentShader: `
uniform vec3 u_color;
void main() {
gl_FragColor = vec4(u_color, 1.0);
}
`,
side: THREE.BackSide,
});
const outlineMesh = new THREE.Mesh(geo, outlineMat);
scene.add(outlineMesh);
return { mesh, outlineMesh, toonMat, outlineMat, gradientMap };
}
export function sceneSetup(THREE, scene, camera, renderer, params, seed) {
camera.position.set(0, 0, 4);
camera.lookAt(0, 0, 0);
return buildScene(THREE, scene, params);
}
export function sceneAnimate(THREE, scene, camera, state, params, time, delta) {
const { mesh, outlineMesh, toonMat, outlineMat } = state;
// Rotate both meshes together
const angle = time * params.rotateSpeed;
mesh.rotation.y = angle;
mesh.rotation.x = angle * 0.4;
outlineMesh.rotation.y = angle;
outlineMesh.rotation.x = angle * 0.4;
// Live updates — hue and outline thickness don't need a rebuild
toonMat.color.setHSL(params.hue / 360, 0.6, 0.55);
outlineMat.uniforms.u_thickness.value = params.outlineThick;
}