About this technique →
hatching/sketch.js
// Hatching — NdotL-driven procedural ink hatching
//
// Custom ShaderMaterial that:
//   1. Computes a lighting term (NdotL) in the vertex shader, interpolated
//      via vTone to the fragment shader.
//   2. Uses object-space position coordinates to build hatching UV so the
//      lines stay fixed on the surface and don't swim with rotation.
//   3. One primary hatch layer always present; a perpendicular cross-hatch
//      layer added in darker (shadowed) regions for deeper tone.
//
// Rebuilt when shape changes (rebuildOnChange).

import * as THREE from 'three';

export const PARAMS = {
  shape:       { value: "Torus Knot", options: ["Torus Knot", "Icosahedron", "Sphere", "Torus"], label: "Shape", folder: "Structure", rebuildOnChange: true },
  hatchScale:  { value: 80, min: 10, max: 300, step: 1, label: "Hatch Density (lines/unit)", folder: "Appearance" },
  hatchAngle:  { value: 0.78, min: 0, max: 3.14, step: 0.01, label: "Hatch Angle (rad)", folder: "Appearance" },
  bands:       { value: 4, min: 2, max: 6, step: 1, label: "Tone Bands", folder: "Appearance" },
  inkColor:    { value: "#0a0a0a", type: "color", label: "Ink", folder: "Appearance" },
  paperColor:  { value: "#f5efe1", type: "color", label: "Paper", folder: "Appearance" },
  rotateSpeed: { value: 0.3, min: 0, max: 2, step: 0.01, label: "Rotation Speed", folder: "Behavior" },
};

const vertexShader = `
  varying vec3 vNormal;
  varying vec3 vPos;

  void main() {
    // Pass object-space data to fragment shader
    vPos = position;
    vNormal = normalize(normalMatrix * normal);
    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
  }
`;

const fragmentShader = `
  uniform vec3  inkColor;
  uniform vec3  paperColor;
  uniform float hatchScale;
  uniform float hatchAngle;
  uniform float bands;

  varying vec3 vNormal;
  varying vec3 vPos;

  float hatchPattern(vec2 uv, float angle, float scale) {
    float s = uv.x * cos(angle) + uv.y * sin(angle);
    return step(0.5, fract(s * scale));
  }

  void main() {
    // Fixed light direction (view space — always same position relative to camera)
    vec3 lightDir = normalize(vec3(0.5, 1.0, 0.6));
    float tone = max(dot(normalize(vNormal), lightDir), 0.0);

    // Quantize tone into discrete bands (toon-like stepping for hatching density)
    float bandedTone = floor(tone * bands) / bands;

    // Hatching UV derived from object-space position
    // Projecting onto a 2D plane so lines don't swim
    vec2 hatchUV = vec2(vPos.x + vPos.z, vPos.y - vPos.z * 0.5);

    float h1 = hatchPattern(hatchUV, hatchAngle, hatchScale);
    // Cross-hatch at 90° offset
    float h2 = hatchPattern(hatchUV, hatchAngle + 1.5708, hatchScale);

    // Primary hatch: drawn everywhere; more opaque on darker areas
    // Cross-hatch: drawn only in shadows (tone < 0.4)
    float ink = (1.0 - h1) * (1.0 - bandedTone)
              + (tone < 0.4 ? (1.0 - h2) * 0.7 : 0.0);
    ink = clamp(ink, 0.0, 1.0);

    vec3 col = mix(paperColor, inkColor, ink * 0.85);
    gl_FragColor = vec4(col, 1.0);
  }
`;

function buildGeometry(THREE, shape) {
  switch (shape) {
    case "Icosahedron": return new THREE.IcosahedronGeometry(1.3, 3);
    case "Sphere":      return new THREE.SphereGeometry(1.4, 64, 32);
    case "Torus":       return new THREE.TorusGeometry(1.1, 0.4, 32, 96);
    default:            return new THREE.TorusKnotGeometry(1, 0.35, 128, 32);
  }
}

export function sceneSetup(THREE, scene, camera, renderer, params, seed) {
  camera.position.set(0, 0, 4);
  camera.lookAt(0, 0, 0);

  scene.background = new THREE.Color(params.paperColor);

  const geo = buildGeometry(THREE, params.shape);

  const mat = new THREE.ShaderMaterial({
    uniforms: {
      inkColor:   { value: new THREE.Color(params.inkColor) },
      paperColor: { value: new THREE.Color(params.paperColor) },
      hatchScale: { value: params.hatchScale },
      hatchAngle: { value: params.hatchAngle },
      bands:      { value: params.bands },
    },
    vertexShader,
    fragmentShader,
  });

  const mesh = new THREE.Mesh(geo, mat);
  scene.add(mesh);

  return { mesh, mat };
}

export function sceneAnimate(THREE, scene, camera, state, params, time, delta) {
  const { mesh, mat } = state;

  // Rotate
  mesh.rotation.y = time * params.rotateSpeed;
  mesh.rotation.x = time * params.rotateSpeed * 0.4;

  // Update uniforms live
  mat.uniforms.inkColor.value.set(params.inkColor);
  mat.uniforms.paperColor.value.set(params.paperColor);
  mat.uniforms.hatchScale.value = params.hatchScale;
  mat.uniforms.hatchAngle.value = params.hatchAngle;
  mat.uniforms.bands.value = params.bands;

  scene.background.set(params.paperColor);
}