Dithered Object
Point it at any GLB or glTF model, SVG, or image and it floats in a lit studio, rendered through a Bayer, halftone, or Floyd–Steinberg dither. Built on three.js.
Demo
Drag to orbit the object, switch the dither pattern, then open the controls to swap in your own GLB/glTF model, SVG, or image by URL or from disk.
Install
npx shadcn@latest add @canvas-ui/dithered-object-reactOr copy the source below into your project. Prefer WGSL? Switch the renderer above to the WebGPU build.
Dependencies
The install command above adds these automatically. If you copy the source by hand, install them yourself.
npm install three
npm install -D @types/threeCode
"use client";
import { useEffect, useRef, useState } from "react";
import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import { DRACOLoader } from "three/addons/loaders/DRACOLoader.js";
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
export type DitherMethod = "bayer" | "halftone" | "floyd";
export interface DitheredObjectOptions {
/** URL of the asset to display: GLB/glTF, SVG, PNG, JPEG, WebP, or GIF. Object URLs from a file input work too. The format is sniffed from the bytes, not the extension. */
src?: string;
/** Dither pattern: an ordered Bayer grid, clustered halftone dots, or Floyd-Steinberg error diffusion. */
method?: DitherMethod;
/** Size of the dither cells in CSS pixels. */
gridSize?: number;
/** Extra pixelation applied on top of the grid size (1 to 10). */
pixelSizeRatio?: number;
/** Collapse the scene to grayscale before dithering. */
grayscale?: boolean;
/** Invert the final colors. */
invert?: boolean;
/** Enable the dither pass. Turn off to see the raw render. */
dither?: boolean;
/** Background color behind the object. Empty string keeps the canvas transparent. */
background?: string;
/** Accent color of the ring light in the studio environment. */
highlight?: string;
/** Brightness of the studio environment lighting. */
environmentIntensity?: number;
/** Roughness override applied to every material (0 to 1). Negative keeps the asset's own values. */
roughness?: number;
/** Size of the longest side of the object in scene units. The camera sits about 4 units away. */
scale?: number;
/** Horizontal offset of the object in scene units. */
xOffset?: number;
/** Vertical offset of the object in scene units. */
yOffset?: number;
/** Strength of the floating bob animation (0 disables). */
floatIntensity?: number;
/** Strength of the idle rocking rotation (0 disables). */
rotationIntensity?: number;
/** Speed of the float and rocking animation. */
floatSpeed?: number;
/** Let the user orbit the camera by dragging. */
orbit?: boolean;
/** Let the user zoom with the scroll wheel or pinch. */
zoom?: boolean;
/** Spin the camera around the object turntable-style. */
autoRotate?: boolean;
/** Turntable speed when autoRotate is on. */
autoRotateSpeed?: number;
/** Camera field of view in degrees. */
fov?: number;
/** Camera distance from the center of the object. */
cameraDistance?: number;
/** Base URL of the Draco decoder, fetched only when a model needs it. */
dracoDecoderPath?: string;
/** Called after an asset finishes loading. */
onLoad?: (() => void) | null;
/** Called when an asset fails to load. */
onError?: ((error: unknown) => void) | null;
}
export interface DitheredObjectElements {
/** Canvas the scene renders to. */
canvas: HTMLCanvasElement;
}
export interface DitheredObjectInstance {
/** Update options live. Changing src loads the new asset. */
setOptions: (options: DitheredObjectOptions) => void;
/** Re-read canvas size. Call when the element is resized. */
resize: () => void;
/** Stop the loop and release all GPU resources. */
destroy: () => void;
}
const DEFAULTS: Required<DitheredObjectOptions> = {
src: "",
method: "bayer",
gridSize: 4,
pixelSizeRatio: 1,
grayscale: true,
invert: false,
dither: true,
background: "",
highlight: "#066aff",
environmentIntensity: 0.1,
roughness: -1,
scale: 3,
xOffset: 0,
yOffset: 0,
floatIntensity: 2,
rotationIntensity: 1,
floatSpeed: 2,
orbit: true,
zoom: false,
autoRotate: false,
autoRotateSpeed: 2,
fov: 65,
cameraDistance: 4.2,
dracoDecoderPath: "https://www.gstatic.com/draco/versioned/decoders/1.5.7/",
onLoad: null,
onError: null,
};
const POST_VERT = `
out vec2 vUv;
void main() {
vUv = position.xy * 0.5 + 0.5;
gl_Position = vec4(position.xy, 0.0, 1.0);
}`;
const SRGB_ENCODE = `
vec3 toSrgb(vec3 c) {
c = clamp(c, 0.0, 1.0);
return mix(c * 12.92, 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, step(vec3(0.0031308), c));
}
`;
const LEVEL_FRAG = `
precision highp float;
out vec4 outColor;
uniform sampler2D tDiffuse;
uniform vec2 uResolution;
uniform float uGridSize;
uniform float uPixelSizeRatio;
${SRGB_ENCODE}
void main() {
vec2 fragCoord = (floor(gl_FragCoord.xy) + 0.5) * uGridSize;
float pixelSize = uGridSize * uPixelSizeRatio;
vec2 pixelUv = (floor(fragCoord / pixelSize) + 0.5) * pixelSize / uResolution;
vec4 tex = texture(tDiffuse, pixelUv);
outColor = vec4(toSrgb(tex.rgb), tex.a);
}`;
const POST_FRAG = `
precision highp float;
in vec2 vUv;
out vec4 outColor;
uniform sampler2D tDiffuse;
uniform vec2 uResolution;
uniform float uGridSize;
uniform float uPixelSizeRatio;
uniform float uGrayscale;
uniform float uInvert;
uniform float uDither;
uniform int uMethod;
uniform sampler2D tMask;
const mat4 THRESHOLDS = mat4(
0.94118, 0.29412, 0.76471, 0.05882,
0.47059, 0.70588, 0.23529, 0.52941,
0.82353, 0.11765, 0.88235, 0.17647,
0.35294, 0.58824, 0.41176, 0.64706
);
const float SCREEN_ANGLE = 0.70710678;
const float CORNER_REACH = 1.41421356;
${SRGB_ENCODE}
float bayerThreshold(vec2 cellCoord) {
ivec2 p = ivec2(mod(cellCoord, 4.0));
return THRESHOLDS[p.x][p.y];
}
float halftoneThreshold(vec2 cellCoord) {
vec2 screen = vec2(
cellCoord.x * SCREEN_ANGLE - cellCoord.y * SCREEN_ANGLE,
cellCoord.x * SCREEN_ANGLE + cellCoord.y * SCREEN_ANGLE
);
return clamp(length(fract(screen) - 0.5) * CORNER_REACH, 0.0, 1.0);
}
float thresholdAt(vec2 cellCoord) {
if (uMethod == 1) return halftoneThreshold(cellCoord);
return bayerThreshold(cellCoord);
}
bool maskAt(vec2 cellCoord) {
ivec2 last = textureSize(tMask, 0) - ivec2(1);
ivec2 cell = clamp(ivec2(floor(cellCoord)), ivec2(0), last);
return texelFetch(tMask, cell, 0).r > 0.5;
}
void main() {
vec2 fragCoord = vUv * uResolution;
if (uDither < 0.5) {
vec4 raw = texture(tDiffuse, vUv);
outColor = vec4(toSrgb(raw.rgb) * raw.a, raw.a);
return;
}
float pixelSize = uGridSize * uPixelSizeRatio;
vec2 pixelUv = (floor(fragCoord / pixelSize) + 0.5) * pixelSize / uResolution;
vec4 tex = texture(tDiffuse, pixelUv);
vec3 color = toSrgb(tex.rgb);
float level = dot(color, vec3(1.0));
if (uGrayscale > 0.5) color = vec3(level);
vec2 cellCoord = fragCoord / uGridSize;
bool lit = uMethod == 2
? maskAt(cellCoord)
: level >= thresholdAt(cellCoord);
if (!lit) color = vec3(0.0);
if (uInvert > 0.5) color = 1.0 - color;
outColor = vec4(color * tex.a, tex.a);
}`;
function diffuse(
pixels: Uint8Array,
mask: Uint8Array,
rows: [Float32Array, Float32Array],
width: number,
height: number,
) {
let current = rows[0];
let next = rows[1];
current.fill(0);
for (let y = 0; y < height; y++) {
next.fill(0);
const row = y * width;
for (let x = 0; x < width; x++) {
const i = (row + x) * 4;
const tone =
Math.min((pixels[i] + pixels[i + 1] + pixels[i + 2]) / 255, 1) +
current[x + 1];
const lit = tone >= 0.5;
mask[row + x] = lit ? 255 : 0;
const error = lit ? tone - 1 : tone;
current[x + 2] += error * 0.4375;
next[x] += error * 0.1875;
next[x + 1] += error * 0.3125;
next[x + 2] += error * 0.0625;
}
const spent = current;
current = next;
next = spent;
}
}
interface FormerDef {
kind: "ring" | "box";
intensity: number;
position: [number, number, number];
scale: [number, number, number];
lookAtCenter?: boolean;
withLight?: boolean;
}
const ROOM_BLOCKS: Array<{
position: [number, number, number];
rotation: [number, number, number];
scale: [number, number, number];
}> = [
{
position: [-10.906, -1, 1.846],
rotation: [0, -0.195, 0],
scale: [2.328, 7.905, 4.651],
},
{
position: [-5.607, -0.754, -0.758],
rotation: [0, 0.994, 0],
scale: [1.97, 1.534, 3.955],
},
{
position: [6.167, -0.16, 7.803],
rotation: [0, 0.561, 0],
scale: [3.927, 6.285, 3.687],
},
{
position: [-2.017, 0.018, 6.124],
rotation: [0, 0.333, 0],
scale: [2.002, 4.566, 2.064],
},
{
position: [2.291, -0.756, -2.621],
rotation: [0, -0.286, 0],
scale: [1.546, 1.552, 1.496],
},
{
position: [-2.193, -0.369, -5.547],
rotation: [0, 0.516, 0],
scale: [3.875, 3.487, 2.986],
},
];
const ROOM_FORMERS: FormerDef[] = [
{
kind: "ring",
intensity: 15,
position: [2, 3, -2],
scale: [10, 10, 10],
lookAtCenter: true,
},
{
kind: "box",
intensity: 80,
position: [-14, 10, 8],
scale: [0.1, 2.5, 2.5],
},
{
kind: "box",
intensity: 80,
position: [-14, 14, -4],
scale: [0.1, 2.5, 2.5],
withLight: true,
},
{
kind: "box",
intensity: 23,
position: [14, 12, 0],
scale: [0.1, 5, 5],
withLight: true,
},
{
kind: "box",
intensity: 16,
position: [0, 9, 14],
scale: [5, 5, 0.1],
withLight: true,
},
{
kind: "box",
intensity: 80,
position: [7, 8, -14],
scale: [2.5, 2.5, 0.1],
withLight: true,
},
{
kind: "box",
intensity: 80,
position: [-7, 16, -14],
scale: [2.5, 2.5, 0.1],
withLight: true,
},
{
kind: "box",
intensity: 1,
position: [0, 20, 0],
scale: [0.1, 0.1, 0.1],
withLight: true,
},
{
kind: "box",
intensity: 20,
position: [0, 15, 0],
scale: [10, 1, 10],
withLight: true,
},
];
const CAMERA_DIR = new THREE.Vector3(0, -1, 4).normalize();
const MODEL_LIFT = 0.3;
const RASTER_SIZE = 2048;
const TRACE_SIZE = 512;
const ALPHA_CUTOFF = 127;
const SIMPLIFY_TOLERANCE = 1;
const MIN_AREA = 6;
const MAX_CONTOURS = 64;
const EXTRUDE_DEPTH = 0.08;
const BEVEL_SIZE = 0.006;
const METHOD_INDEX: Record<DitherMethod, number> = {
bayer: 0,
halftone: 1,
floyd: 2,
};
type AssetKind = "glb" | "gltf" | "svg" | "bitmap";
function sniffKind(bytes: Uint8Array): AssetKind | null {
if (bytes.length < 4) return null;
const ascii = (start: number, text: string) => {
for (let i = 0; i < text.length; i++) {
if (bytes[start + i] !== text.charCodeAt(i)) return false;
}
return true;
};
if (ascii(0, "glTF")) return "glb";
if (bytes[0] === 0x89 && ascii(1, "PNG")) return "bitmap";
if (bytes[0] === 0xff && bytes[1] === 0xd8) return "bitmap";
if (ascii(0, "RIFF") && ascii(8, "WEBP")) return "bitmap";
if (ascii(0, "GIF8")) return "bitmap";
let head = "";
try {
head = new TextDecoder()
.decode(bytes.subarray(0, 2048))
.replace(/^\uFEFF/, "")
.trimStart();
} catch {
return null;
}
if (head.startsWith("{")) return "gltf";
if (head.startsWith("<")) return head.includes("<svg") ? "svg" : null;
return null;
}
function makeCanvas(width: number, height: number) {
const canvas = document.createElement("canvas");
canvas.width = Math.max(1, Math.round(width));
canvas.height = Math.max(1, Math.round(height));
return canvas;
}
function drawToCanvas(
source: CanvasImageSource,
width: number,
height: number,
) {
const canvas = makeCanvas(width, height);
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("2d context unavailable");
ctx.drawImage(source, 0, 0, canvas.width, canvas.height);
return canvas;
}
function decodeWithImage(blob: Blob): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const url = URL.createObjectURL(blob);
const image = new Image();
image.onload = () => {
URL.revokeObjectURL(url);
resolve(image);
};
image.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error("Could not decode the image"));
};
image.src = url;
});
}
async function decodeWithBitmap(blob: Blob): Promise<HTMLCanvasElement | null> {
if (typeof createImageBitmap !== "function") return null;
try {
const bitmap = await createImageBitmap(blob);
const longest = Math.max(bitmap.width, bitmap.height, 1);
const scale = Math.min(1, RASTER_SIZE / longest);
const canvas = drawToCanvas(
bitmap,
bitmap.width * scale,
bitmap.height * scale,
);
bitmap.close();
return canvas;
} catch {
return null;
}
}
async function decodeImage(
blob: Blob,
kind: AssetKind,
): Promise<HTMLCanvasElement> {
const vector = kind === "svg";
if (!vector) {
const decoded = await decodeWithBitmap(blob);
if (decoded) return decoded;
}
const image = await decodeWithImage(blob);
const width = image.naturalWidth || RASTER_SIZE;
const height = image.naturalHeight || RASTER_SIZE;
const longest = Math.max(width, height, 1);
const scale = vector
? RASTER_SIZE / longest
: Math.min(1, RASTER_SIZE / longest);
return drawToCanvas(image, width * scale, height * scale);
}
function traceContours(inside: Uint8Array, width: number, height: number) {
const segments: number[] = [];
for (let y = 0; y < height - 1; y++) {
for (let x = 0; x < width - 1; x++) {
const base = y * width + x;
const code =
inside[base] |
(inside[base + 1] << 1) |
(inside[base + width + 1] << 2) |
(inside[base + width] << 3);
if (code === 0 || code === 15) continue;
const top = x + 0.5;
const right = y + 0.5;
switch (code) {
case 1:
case 14:
segments.push(x, right, top, y);
break;
case 2:
case 13:
segments.push(top, y, x + 1, right);
break;
case 3:
case 12:
segments.push(x, right, x + 1, right);
break;
case 4:
case 11:
segments.push(x + 1, right, top, y + 1);
break;
case 6:
case 9:
segments.push(top, y, top, y + 1);
break;
case 7:
case 8:
segments.push(x, right, top, y + 1);
break;
case 5:
segments.push(x, right, top, y, x + 1, right, top, y + 1);
break;
default:
segments.push(top, y, x + 1, right, x, right, top, y + 1);
break;
}
}
}
const count = segments.length / 4;
const stride = width * 2 + 1;
const ends = new Map<number, number[]>();
const keyAt = (index: number) =>
segments[index * 2 + 1] * 2 * stride + segments[index * 2] * 2;
for (let i = 0; i < count; i++) {
for (const end of [i * 2, i * 2 + 1]) {
const key = keyAt(end);
const bucket = ends.get(key);
if (bucket) bucket.push(i);
else ends.set(key, [i]);
}
}
const used = new Uint8Array(count);
const contours: number[][] = [];
for (let start = 0; start < count; start++) {
if (used[start]) continue;
const points: number[] = [];
let current = start;
let x = segments[start * 4];
let y = segments[start * 4 + 1];
while (current >= 0 && !used[current]) {
used[current] = 1;
const head = current * 4;
const forward = segments[head] === x && segments[head + 1] === y;
x = forward ? segments[head + 2] : segments[head];
y = forward ? segments[head + 3] : segments[head + 1];
points.push(x, y);
const bucket = ends.get(y * 2 * stride + x * 2);
let next = -1;
if (bucket) {
for (const candidate of bucket) {
if (!used[candidate]) {
next = candidate;
break;
}
}
}
current = next;
}
if (points.length >= 8) contours.push(points);
}
return contours;
}
function simplify(points: number[], tolerance: number) {
const count = points.length / 2;
if (count < 4) return points;
const keep = new Uint8Array(count);
keep[0] = 1;
keep[count - 1] = 1;
const stack = [0, count - 1];
const toleranceSq = tolerance * tolerance;
while (stack.length) {
const last = stack.pop() as number;
const first = stack.pop() as number;
if (last - first < 2) continue;
const ax = points[first * 2];
const ay = points[first * 2 + 1];
const dx = points[last * 2] - ax;
const dy = points[last * 2 + 1] - ay;
const lengthSq = dx * dx + dy * dy;
let farthest = -1;
let farthestSq = toleranceSq;
for (let i = first + 1; i < last; i++) {
const px = points[i * 2] - ax;
const py = points[i * 2 + 1] - ay;
const t = lengthSq > 0 ? (px * dx + py * dy) / lengthSq : 0;
const clamped = t < 0 ? 0 : t > 1 ? 1 : t;
const ox = px - dx * clamped;
const oy = py - dy * clamped;
const distanceSq = ox * ox + oy * oy;
if (distanceSq > farthestSq) {
farthest = i;
farthestSq = distanceSq;
}
}
if (farthest < 0) continue;
keep[farthest] = 1;
stack.push(first, farthest, farthest, last);
}
const result: number[] = [];
for (let i = 0; i < count; i++) {
if (keep[i]) result.push(points[i * 2], points[i * 2 + 1]);
}
return result;
}
function ringArea(points: number[]) {
let area = 0;
for (let i = 0, j = points.length - 2; i < points.length; j = i, i += 2) {
area += (points[j] - points[i]) * (points[j + 1] + points[i + 1]);
}
return Math.abs(area) / 2;
}
function ringContains(points: number[], x: number, y: number) {
let inside = false;
for (let i = 0, j = points.length - 2; i < points.length; j = i, i += 2) {
const yi = points[i + 1];
const yj = points[j + 1];
if (yi > y === yj > y) continue;
const t = (y - yi) / (yj - yi);
if (x < points[i] + t * (points[j] - points[i])) inside = !inside;
}
return inside;
}
function buildShapes(
canvas: HTMLCanvasElement,
aspectW: number,
aspectH: number,
) {
const rectangle = () =>
new THREE.Shape([
new THREE.Vector2(0, 0),
new THREE.Vector2(aspectW, 0),
new THREE.Vector2(aspectW, aspectH),
new THREE.Vector2(0, aspectH),
]);
const scale = Math.min(
1,
TRACE_SIZE / Math.max(canvas.width, canvas.height, 1),
);
const trace =
scale < 1
? drawToCanvas(canvas, canvas.width * scale, canvas.height * scale)
: canvas;
const ctx = trace.getContext("2d", { willReadFrequently: true });
if (!ctx) return [rectangle()];
const traceW = trace.width;
const traceH = trace.height;
const data = ctx.getImageData(0, 0, traceW, traceH).data;
const width = traceW + 2;
const height = traceH + 2;
const inside = new Uint8Array(width * height);
let covered = 0;
for (let y = 0; y < traceH; y++) {
for (let x = 0; x < traceW; x++) {
const on = data[(y * traceW + x) * 4 + 3] >= ALPHA_CUTOFF ? 1 : 0;
inside[(y + 1) * width + x + 1] = on;
covered += on;
}
}
if (covered >= traceW * traceH * 0.995) return [rectangle()];
const rings = traceContours(inside, width, height)
.map((points) => simplify(points, SIMPLIFY_TOLERANCE))
.filter((points) => points.length >= 6 && ringArea(points) >= MIN_AREA)
.map((points) => ({ points, area: ringArea(points), depth: 0 }))
.sort((a, b) => b.area - a.area)
.slice(0, MAX_CONTOURS);
if (!rings.length) return [rectangle()];
for (const ring of rings) {
for (const other of rings) {
if (
other !== ring &&
other.area > ring.area &&
ringContains(other.points, ring.points[0], ring.points[1])
) {
ring.depth += 1;
}
}
}
const toPath = (points: number[]) => {
const path: THREE.Vector2[] = [];
for (let i = 0; i < points.length; i += 2) {
path.push(
new THREE.Vector2(
((points[i] - 0.5) / traceW) * aspectW,
(1 - (points[i + 1] - 0.5) / traceH) * aspectH,
),
);
}
return path;
};
const shapes = new Map<(typeof rings)[number], THREE.Shape>();
for (const ring of rings) {
if (ring.depth % 2 === 0)
shapes.set(ring, new THREE.Shape(toPath(ring.points)));
}
for (const ring of rings) {
if (ring.depth % 2 === 0) continue;
let parent: (typeof rings)[number] | null = null;
for (const other of rings) {
if (other.depth !== ring.depth - 1) continue;
if (!ringContains(other.points, ring.points[0], ring.points[1])) continue;
if (!parent || other.area < parent.area) parent = other;
}
const shape = parent ? shapes.get(parent) : undefined;
if (shape) shape.holes.push(new THREE.Path(toPath(ring.points)));
}
const result = [...shapes.values()];
return result.length ? result : [rectangle()];
}
function createImageObject(
canvas: HTMLCanvasElement,
anisotropy: number,
): THREE.Mesh {
const longest = Math.max(canvas.width, canvas.height, 1);
const aspectW = canvas.width / longest;
const aspectH = canvas.height / longest;
const geometry = new THREE.ExtrudeGeometry(
buildShapes(canvas, aspectW, aspectH),
{
depth: EXTRUDE_DEPTH,
bevelEnabled: true,
bevelThickness: BEVEL_SIZE,
bevelSize: BEVEL_SIZE,
bevelOffset: 0,
bevelSegments: 2,
steps: 1,
curveSegments: 1,
},
);
const position = geometry.getAttribute("position");
const uv = new Float32Array(position.count * 2);
for (let i = 0; i < position.count; i++) {
uv[i * 2] = position.getX(i) / aspectW;
uv[i * 2 + 1] = position.getY(i) / aspectH;
}
geometry.setAttribute("uv", new THREE.BufferAttribute(uv, 2));
const texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
texture.anisotropy = anisotropy;
const material = new THREE.MeshStandardMaterial({
map: texture,
roughness: 0.6,
metalness: 0,
});
return new THREE.Mesh(geometry, material);
}
function disposeObject(root: THREE.Object3D) {
root.traverse((node) => {
const mesh = node as THREE.Mesh;
if (mesh.geometry) mesh.geometry.dispose();
const materials = Array.isArray(mesh.material)
? mesh.material
: [mesh.material];
for (const material of materials) {
if (!material) continue;
for (const value of Object.values(material)) {
if (!(value instanceof THREE.Texture)) continue;
value.dispose();
}
material.dispose();
}
});
}
export function createDitheredObject(
elements: DitheredObjectElements,
options: DitheredObjectOptions = {},
): DitheredObjectInstance | null {
const { canvas } = elements;
const config: Required<DitheredObjectOptions> = { ...DEFAULTS, ...options };
let renderer: THREE.WebGLRenderer;
try {
renderer = new THREE.WebGLRenderer({
canvas,
antialias: false,
alpha: true,
powerPreference: "high-performance",
});
} catch {
return null;
}
renderer.toneMapping = THREE.ACESFilmicToneMapping;
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(config.fov, 1, 0.1, 200);
camera.position.copy(CAMERA_DIR).multiplyScalar(config.cameraDistance);
const floatGroup = new THREE.Group();
floatGroup.position.y = MODEL_LIFT;
const fitGroup = new THREE.Group();
floatGroup.add(fitGroup);
scene.add(floatGroup);
const controls = new OrbitControls(camera, canvas);
controls.enableDamping = true;
controls.enablePan = false;
const target = new THREE.WebGLRenderTarget(1, 1, { samples: 4 });
target.texture.colorSpace = THREE.SRGBColorSpace;
const postMaterial = new THREE.ShaderMaterial({
glslVersion: THREE.GLSL3,
vertexShader: POST_VERT,
fragmentShader: POST_FRAG,
uniforms: {
tDiffuse: { value: target.texture },
uResolution: { value: new THREE.Vector2(1, 1) },
uGridSize: { value: 4 },
uPixelSizeRatio: { value: 1 },
uGrayscale: { value: 1 },
uInvert: { value: 0 },
uDither: { value: 1 },
uMethod: { value: 0 },
tMask: { value: null },
},
depthTest: false,
depthWrite: false,
blending: THREE.NoBlending,
});
const postGeometry = new THREE.BufferGeometry();
postGeometry.setAttribute(
"position",
new THREE.BufferAttribute(
new Float32Array([-1, -1, 0, 3, -1, 0, -1, 3, 0]),
3,
),
);
const postMesh = new THREE.Mesh(postGeometry, postMaterial);
postMesh.frustumCulled = false;
const postScene = new THREE.Scene();
postScene.add(postMesh);
const postCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
const levelMaterial = new THREE.ShaderMaterial({
glslVersion: THREE.GLSL3,
vertexShader: POST_VERT,
fragmentShader: LEVEL_FRAG,
uniforms: {
tDiffuse: { value: target.texture },
uResolution: postMaterial.uniforms.uResolution,
uGridSize: postMaterial.uniforms.uGridSize,
uPixelSizeRatio: postMaterial.uniforms.uPixelSizeRatio,
},
depthTest: false,
depthWrite: false,
blending: THREE.NoBlending,
});
const levelMesh = new THREE.Mesh(postGeometry, levelMaterial);
levelMesh.frustumCulled = false;
const levelScene = new THREE.Scene();
levelScene.add(levelMesh);
const diffusion = {
target: null as THREE.WebGLRenderTarget | null,
texture: null as THREE.DataTexture | null,
pixels: new Uint8Array(0),
mask: new Uint8Array(0),
rows: [new Float32Array(0), new Float32Array(0)] as [
Float32Array,
Float32Array,
],
width: 0,
height: 0,
generation: 0,
pending: false,
ready: false,
};
const pmrem = new THREE.PMREMGenerator(renderer);
let roomScene: THREE.Scene | null = null;
let ringMaterial: THREE.MeshBasicMaterial | null = null;
let envTarget: THREE.WebGLRenderTarget | null = null;
let envDirty = true;
function buildRoom() {
roomScene = new THREE.Scene();
const room = new THREE.Group();
room.position.set(0, -0.5, 0);
roomScene.add(room);
for (const [x, z] of [
[-15, 15],
[15, 15],
[15, -15],
[-15, -15],
]) {
const spot = new THREE.SpotLight(0xffffff, 2, 0, 0.2, 1, 0);
spot.position.set(x, 20, z);
room.add(spot, spot.target);
}
const center = new THREE.PointLight(0xffffff, 100, 28, 2);
center.position.set(0.5, 14, 0.5);
room.add(center);
const box = new THREE.BoxGeometry();
const shell = new THREE.Mesh(
box,
new THREE.MeshStandardMaterial({ color: "gray", side: THREE.BackSide }),
);
shell.position.set(0, 13.2, 0);
shell.scale.set(31.5, 28.5, 31.5);
room.add(shell);
const white = new THREE.MeshStandardMaterial({ color: 0xffffff });
for (const def of ROOM_BLOCKS) {
const mesh = new THREE.Mesh(box, white);
mesh.position.set(...def.position);
mesh.rotation.set(...def.rotation);
mesh.scale.set(...def.scale);
room.add(mesh);
}
for (const def of ROOM_FORMERS) {
const geometry =
def.kind === "ring"
? new THREE.RingGeometry(0.5, 1, 64)
: new THREE.BoxGeometry();
const material = new THREE.MeshBasicMaterial({
side: THREE.DoubleSide,
toneMapped: false,
});
material.color
.set(def.kind === "ring" ? config.highlight : "#ffffff")
.multiplyScalar(def.intensity);
if (def.kind === "ring") ringMaterial = material;
const mesh = new THREE.Mesh(geometry, material);
mesh.position.set(...def.position);
mesh.scale.set(...def.scale);
if (def.lookAtCenter) mesh.lookAt(0, 0, 0);
room.add(mesh);
if (def.withLight) {
const light = new THREE.PointLight(0xffffff, 100, 28, 2);
light.position.set(...def.position);
room.add(light);
}
}
}
function refreshEnvironment() {
if (!roomScene) buildRoom();
if (ringMaterial) {
ringMaterial.color.set(config.highlight).multiplyScalar(15);
}
envTarget?.dispose();
envTarget = pmrem.fromScene(roomScene!, 0, 0.1, 1000);
scene.environment = envTarget.texture;
}
let model: THREE.Object3D | null = null;
let modelMaxDim = 1;
let loadedSrc: string | null = null;
let loadToken = 0;
let disposed = false;
const loader = new GLTFLoader();
const draco = new DRACOLoader();
draco.setDecoderPath(config.dracoDecoderPath);
loader.setDRACOLoader(draco);
function applyRoughness() {
if (!model) return;
model.traverse((node) => {
const mesh = node as THREE.Mesh;
const materials = Array.isArray(mesh.material)
? mesh.material
: [mesh.material];
for (const material of materials) {
const standard = material as THREE.MeshStandardMaterial;
if (!standard || typeof standard.roughness !== "number") continue;
if (standard.userData.baseRoughness === undefined) {
standard.userData.baseRoughness = standard.roughness;
}
standard.roughness =
config.roughness >= 0
? config.roughness
: standard.userData.baseRoughness;
}
});
}
function applyFit() {
if (!model) return;
fitGroup.scale.setScalar(config.scale / modelMaxDim);
}
function clearModel() {
if (!model) return;
fitGroup.remove(model);
disposeObject(model);
model = null;
}
function adoptModel(object: THREE.Object3D) {
clearModel();
model = object;
const bounds = new THREE.Box3().setFromObject(model);
const size = bounds.getSize(new THREE.Vector3());
const offset = bounds.getCenter(new THREE.Vector3());
modelMaxDim = Math.max(size.x, size.y, size.z, 1e-4);
model.position.sub(offset);
applyRoughness();
applyFit();
fitGroup.add(model);
}
async function loadAsset() {
const src = config.src;
if (src === loadedSrc) return;
loadedSrc = src;
const token = ++loadToken;
if (!src) {
clearModel();
return;
}
try {
const response = await fetch(src);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const buffer = await response.arrayBuffer();
if (disposed || token !== loadToken) return;
const bytes = new Uint8Array(buffer);
const kind = sniffKind(bytes);
if (!kind) throw new Error("Unrecognized asset format");
if (kind === "glb" || kind === "gltf") {
draco.setDecoderPath(config.dracoDecoderPath);
const resourcePath = src.slice(0, src.lastIndexOf("/") + 1);
const data = kind === "glb" ? buffer : new TextDecoder().decode(bytes);
const gltf = await loader.parseAsync(data, resourcePath);
if (disposed || token !== loadToken) {
disposeObject(gltf.scene);
return;
}
adoptModel(gltf.scene);
} else {
const blob = new Blob([buffer], {
type: kind === "svg" ? "image/svg+xml" : "",
});
const source = await decodeImage(blob, kind);
if (disposed || token !== loadToken) return;
adoptModel(
createImageObject(source, renderer.capabilities.getMaxAnisotropy()),
);
}
config.onLoad?.();
} catch (error) {
if (disposed || token !== loadToken) return;
config.onError?.(error);
}
}
const motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
let reducedMotion = motionQuery.matches;
const onMotionChange = () => {
reducedMotion = motionQuery.matches;
if (reducedMotion) floatGroup.rotation.set(0, 0, 0);
applyOptions();
};
motionQuery.addEventListener("change", onMotionChange);
function releaseDiffusion() {
diffusion.target?.dispose();
diffusion.texture?.dispose();
diffusion.target = null;
diffusion.texture = null;
}
function methodIndex() {
const index = METHOD_INDEX[config.method] ?? 0;
return index === 2 && !diffusion.ready ? 0 : index;
}
function resizeDiffusion(width: number, height: number) {
if (
diffusion.target &&
diffusion.width === width &&
diffusion.height === height
) {
return;
}
releaseDiffusion();
diffusion.width = width;
diffusion.height = height;
diffusion.generation += 1;
diffusion.ready = false;
diffusion.target = new THREE.WebGLRenderTarget(width, height, {
depthBuffer: false,
stencilBuffer: false,
minFilter: THREE.NearestFilter,
magFilter: THREE.NearestFilter,
});
diffusion.pixels = new Uint8Array(width * height * 4);
diffusion.mask = new Uint8Array(width * height);
diffusion.rows = [new Float32Array(width + 2), new Float32Array(width + 2)];
diffusion.texture = new THREE.DataTexture(
diffusion.mask,
width,
height,
THREE.RedFormat,
);
diffusion.texture.needsUpdate = true;
postMaterial.uniforms.tMask.value = diffusion.texture;
postMaterial.uniforms.uMethod.value = methodIndex();
}
function updateDiffusion() {
if (diffusion.pending) return;
const resolution = postMaterial.uniforms.uResolution.value as THREE.Vector2;
const gridSize = postMaterial.uniforms.uGridSize.value as number;
resizeDiffusion(
Math.max(Math.ceil(resolution.x / gridSize), 1),
Math.max(Math.ceil(resolution.y / gridSize), 1),
);
const surface = diffusion.target;
if (!surface) return;
renderer.setRenderTarget(surface);
renderer.render(levelScene, postCamera);
const { generation, width, height, pixels } = diffusion;
diffusion.pending = true;
renderer
.readRenderTargetPixelsAsync(surface, 0, 0, width, height, pixels)
.then(() => {
diffusion.pending = false;
if (disposed || generation !== diffusion.generation) return;
diffuse(pixels, diffusion.mask, diffusion.rows, width, height);
if (diffusion.texture) diffusion.texture.needsUpdate = true;
if (!diffusion.ready) {
diffusion.ready = true;
postMaterial.uniforms.uMethod.value = methodIndex();
}
})
.catch(() => {
diffusion.pending = false;
});
}
function applyOptions() {
renderer.setClearColor(
new THREE.Color(config.background || "#000000"),
config.background ? 1 : 0,
);
scene.environmentIntensity = config.environmentIntensity;
controls.enableRotate = config.orbit;
controls.enableZoom = config.zoom;
controls.autoRotate = config.autoRotate && !reducedMotion;
controls.autoRotateSpeed = config.autoRotateSpeed;
camera.fov = config.fov;
camera.updateProjectionMatrix();
floatGroup.position.x = config.xOffset;
floatGroup.position.y = MODEL_LIFT + config.yOffset;
const pr = renderer.getPixelRatio();
postMaterial.uniforms.uGridSize.value = Math.max(config.gridSize, 1) * pr;
postMaterial.uniforms.uPixelSizeRatio.value = Math.max(
config.pixelSizeRatio,
1,
);
postMaterial.uniforms.uGrayscale.value = config.grayscale ? 1 : 0;
postMaterial.uniforms.uInvert.value = config.invert ? 1 : 0;
postMaterial.uniforms.uDither.value = config.dither ? 1 : 0;
postMaterial.uniforms.uMethod.value = methodIndex();
applyRoughness();
applyFit();
}
function resize() {
const width = Math.max(canvas.clientWidth, 1);
const height = Math.max(canvas.clientHeight, 1);
const pr = Math.min(window.devicePixelRatio || 1, 2);
renderer.setPixelRatio(pr);
renderer.setSize(width, height, false);
const pixelSize = config.dither
? Math.max(config.gridSize, 1) * Math.max(config.pixelSizeRatio, 1) * pr
: 1;
const targetScale = Math.min(1, 2 / pixelSize);
target.setSize(
Math.max(Math.round(width * pr * targetScale), 1),
Math.max(Math.round(height * pr * targetScale), 1),
);
postMaterial.uniforms.uResolution.value.set(
Math.round(width * pr),
Math.round(height * pr),
);
postMaterial.uniforms.uGridSize.value = Math.max(config.gridSize, 1) * pr;
camera.aspect = width / height;
camera.updateProjectionMatrix();
}
const observer = new ResizeObserver(resize);
observer.observe(canvas);
resize();
applyOptions();
loadAsset();
let inView = true;
let loopRunning = false;
function tick(time: number) {
if (!inView) {
lastTime = 0;
stopLoop();
return;
}
const delta = lastTime ? Math.min((time - lastTime) / 1000, 0.1) : 0;
lastTime = time;
if (envDirty) {
envDirty = false;
refreshEnvironment();
}
controls.update();
if (!reducedMotion) {
elapsed += delta * config.floatSpeed;
floatGroup.rotation.x =
(Math.cos(elapsed / 4) / 8) * config.rotationIntensity;
floatGroup.rotation.y =
(Math.sin(elapsed / 4) / 8) * config.rotationIntensity;
floatGroup.rotation.z =
(Math.sin(elapsed / 4) / 20) * config.rotationIntensity;
floatGroup.position.y =
MODEL_LIFT +
config.yOffset +
(Math.sin(elapsed / 1.5) / 10) * config.floatIntensity;
}
renderer.setRenderTarget(target);
renderer.render(scene, camera);
if (config.dither && config.method === "floyd") updateDiffusion();
renderer.setRenderTarget(null);
renderer.render(postScene, postCamera);
}
function startLoop() {
if (loopRunning || !inView || disposed) return;
loopRunning = true;
renderer.setAnimationLoop(tick);
}
function stopLoop() {
if (!loopRunning) return;
loopRunning = false;
renderer.setAnimationLoop(null);
}
const viewObserver =
typeof IntersectionObserver !== "undefined"
? new IntersectionObserver((entries) => {
inView = entries[entries.length - 1]?.isIntersecting ?? true;
if (inView) {
startLoop();
} else {
stopLoop();
}
})
: null;
viewObserver?.observe(canvas);
let lastTime = 0;
let elapsed = Math.random() * 100;
startLoop();
return {
setOptions(next: DitheredObjectOptions) {
let changed = false;
for (const [key, value] of Object.entries(next)) {
if (typeof value === "function") continue;
if (config[key as keyof DitheredObjectOptions] !== value) {
changed = true;
break;
}
}
if (!changed) {
Object.assign(config, next);
return;
}
const previousHighlight = config.highlight;
const previousDistance = config.cameraDistance;
Object.assign(config, next);
if (config.highlight !== previousHighlight) envDirty = true;
if (config.cameraDistance !== previousDistance) {
camera.position.copy(CAMERA_DIR).multiplyScalar(config.cameraDistance);
}
applyOptions();
resize();
loadAsset();
startLoop();
},
resize,
destroy() {
disposed = true;
loadToken += 1;
stopLoop();
observer.disconnect();
viewObserver?.disconnect();
motionQuery.removeEventListener("change", onMotionChange);
controls.dispose();
clearModel();
if (roomScene) disposeObject(roomScene);
envTarget?.dispose();
pmrem.dispose();
draco.dispose();
target.dispose();
releaseDiffusion();
levelMaterial.dispose();
postGeometry.dispose();
postMaterial.dispose();
renderer.dispose();
},
};
}
export interface DitheredObjectProps extends DitheredObjectOptions {
className?: string;
style?: React.CSSProperties;
}
export function DitheredObject({
className,
style,
...options
}: DitheredObjectProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const instanceRef = useRef<DitheredObjectInstance | null>(null);
const [initialOptions] = useState(options);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
instanceRef.current = createDitheredObject({ canvas }, initialOptions);
return () => {
instanceRef.current?.destroy();
instanceRef.current = null;
};
}, [initialOptions]);
useEffect(() => {
instanceRef.current?.setOptions(options);
});
return (
<div className={className} style={{ position: "relative", ...style }}>
<canvas
ref={canvasRef}
style={{
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
display: "block",
touchAction: "none",
}}
/>
</div>
);
}
export default DitheredObject;
API reference
| Property | Type | Default |
|---|---|---|
srcURL of the asset to display: GLB/glTF, SVG, PNG, JPEG, WebP, or GIF. Object URLs from a file input work too. The format is sniffed from the bytes, not the extension. Draco-compressed models are supported via a decoder fetched on demand, and flat art is mounted as a card lit by the same studio. | string | |
methodDither pattern: an ordered Bayer grid, clustered halftone dots, or Floyd–Steinberg error diffusion. | "bayer" | "halftone" | "floyd" | "bayer" |
gridSizeSize of the dither cells in CSS pixels. | number | 4 |
pixelSizeRatioExtra pixelation applied on top of the grid size (1 to 10). | number | 1 |
grayscaleCollapse the scene to grayscale before dithering. | boolean | true |
invertInvert the final colors. | boolean | false |
ditherEnable the dither pass. Turn off to see the raw render. | boolean | true |
backgroundBackground color behind the object. Leave empty for a transparent canvas. | string | "" (transparent) |
highlightAccent color of the ring light in the studio environment. | string | "#066aff" |
environmentIntensityBrightness of the studio environment lighting. | number | 0.1 |
roughnessRoughness override applied to every material (0 to 1). Negative keeps the asset's own values. | number | -1 |
scaleSize of the longest side of the object in scene units. The camera sits about 4 units away. | number | 3 |
xOffsetHorizontal offset of the object in scene units. | number | 0 |
yOffsetVertical offset of the object in scene units. | number | 0 |
floatIntensityStrength of the floating bob animation (0 disables). | number | 2 |
rotationIntensityStrength of the idle rocking rotation (0 disables). | number | 1 |
floatSpeedSpeed of the float and rocking animation. | number | 2 |
orbitLet the user orbit the camera by dragging. | boolean | true |
zoomLet the user zoom with the scroll wheel or pinch. | boolean | false |
autoRotateSpin the camera around the object turntable-style. | boolean | false |
autoRotateSpeedTurntable speed when autoRotate is on. | number | 2 |
fovCamera field of view in degrees. | number | 65 |
cameraDistanceCamera distance from the center of the object. | number | 4.2 |
dracoDecoderPathBase URL of the Draco decoder, fetched only when a model needs it. | string | "https://www.gstatic.com/draco/versioned/decoders/1.5.7/" |
onLoadCalled after an asset finishes loading. | () => void | |
onErrorCalled when an asset fails to load. | (error: unknown) => void | |
classNameClasses applied to the wrapper element. | string |