Codrops 원본
Breaking the Frame: Building a Real-Time Datamosh Effect with Three.js
배경 · MIT
갤러리 안에서는 화면을 벗어나거나 움직임을 멈추면 예제도 닫힙니다. 다시 재생할 때는 처음부터 시작해요.
SOURCE FILES
원본 코드 읽기
수집한 원본 소스와 실행 안내를 함께 제공합니다.
datamosh-demo/index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta name="color-scheme" content="dark" />
<meta
name="description"
content="A minimal real-time datamosh implementation using three.js motion vectors, macroblocks and a feedback shader."
/>
<title>Real-Time Datamosh in WebGL</title>
<link rel="icon" href="data:," />
<style>
html,
body {
height: 100%;
margin: 0;
overflow: hidden;
background: #000;
}
canvas {
display: block;
width: 100%;
height: 100%;
user-select: none;
-webkit-user-select: none;
/* A touch drag on the view must not scroll the page. */
touch-action: none;
}
/* Doubled class: Tweakpane injects its own 256px rule after this one. */
.tp-dfwv.tp-dfwv {
width: 300px;
max-height: calc(100dvh - 16px);
overflow-y: auto;
}
.tp-dfwv .tp-rotv {
--tp-blade-value-width: 140px;
}
</style>
</head>
<body>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
datamosh-demo/src/datamosh/effect.ts
import { BlendFunction, Effect, EffectAttribute } from 'postprocessing';
import * as THREE from 'three';
import { controls } from '../debug';
import { mainImageShader } from './shaders';
/**
* Effect wants its uniforms declared in JS as well as in the shader. Reading
* them straight off the source keeps the two from drifting apart: add a uniform
* to the GLSL and it exists here, with a value of the right shape.
*/
const uniformsOf = (shader: string) =>
new Map<string, THREE.Uniform>(
[...shader.matchAll(/uniform\s+(\w+)\s+(\w+);/g)].map(([, type, name]) => [
name,
new THREE.Uniform(
type === 'mat4' ? new THREE.Matrix4()
: type === 'vec2' ? new THREE.Vector2(1, 1)
: type === 'sampler2D' ? null
: 0,
),
]),
);
export class DataMoshEffect extends Effect {
private readonly previousMatrixWorld = new THREE.Matrix4();
private readonly previousProjection = new THREE.Matrix4();
private readonly scratchMatrix = new THREE.Matrix4();
private hasPreviousCamera = false;
constructor(private readonly camera: THREE.Camera) {
// SRC replaces the pixel instead of blending over it (the shader does its
// own mixing), and DEPTH asks the composer for the depth texture the
// camera-motion fallback unprojects through.
super('DataMosh', mainImageShader, {
uniforms: uniformsOf(mainImageShader),
blendFunction: BlendFunction.SRC,
attributes: EffectAttribute.DEPTH,
});
}
set(name: string, value: number | THREE.Texture): void {
this.uniforms.get(name)!.value = value;
}
/**
* The history and velocity textures can be smaller than the frame the pass
* decodes. The Catmull-Rom taps and the residual's low-pass reads position
* themselves in texels of *those* textures, so they need the real size, not
* the pass resolution.
*/
setHistoryResolution(width: number, height: number): void {
(this.uniforms.get('uHistoryResolution')!.value as THREE.Vector2).set(width, height);
}
capturePreviousState(): void {
this.previousMatrixWorld.copy(this.camera.matrixWorld);
this.previousProjection.copy(this.camera.projectionMatrix);
this.hasPreviousCamera = true;
}
/**
* Called by the composer every frame: hands the shader the matrices it needs
* to walk a pixel from this frame back to the previous one. The rotation-only
* variant is the same view with the camera left where it is now, which is
* what the parallax slider mixes towards.
*/
update(): void {
this.set('uTime', performance.now());
const camera = this.camera;
const matrix = (name: string) => this.uniforms.get(name)!.value as THREE.Matrix4;
matrix('uInvViewProjection')
.copy(camera.matrixWorld)
.multiply(camera.projectionMatrixInverse);
const matrixWorld = this.hasPreviousCamera ? this.previousMatrixWorld : camera.matrixWorld;
const projection = this.hasPreviousCamera ? this.previousProjection : camera.projectionMatrix;
matrix('uPrevViewProjection').multiplyMatrices(
projection,
this.scratchMatrix.copy(matrixWorld).invert(),
);
this.scratchMatrix.copy(matrixWorld).copyPosition(camera.matrixWorld);
matrix('uPrevViewProjectionRot').multiplyMatrices(projection, this.scratchMatrix.invert());
}
/**
* Panel to shader by naming convention: `uBlockSize` takes `controls.blockSize`.
* Only the four that are not a plain number copy need spelling out.
*/
applySettings(): void {
for (const [name, uniform] of this.uniforms) {
const value = (controls as Record<string, unknown>)[
name[1].toLowerCase() + name.slice(2)
];
if (typeof value === 'number') uniform.value = value;
}
this.set('uBlockiness', controls.macroblocks ? controls.blockiness : 0);
this.set('uResidualGain', controls.residualGain / 10);
this.set('uDebugMotion', controls.debugMotion ? 1 : 0);
this.set('uShowLostSectors', controls.showLostSectors ? 1 : 0);
this.set('uShowMotionArrows', controls.showMotionArrows ? 1 : 0);
this.set('uDebugField', controls.debugView === 'arrows' ? 0 : 1);
this.set('uDebugArrows', controls.debugView === 'field' ? 0 : 1);
}
}
함께 쓰는 파일 38개 보기
datamosh-demo/src/datamosh/index.ts
import { CopyPass, EffectComposer, EffectPass, FXAAEffect, RenderPass } from 'postprocessing';
import * as THREE from 'three';
import { controls } from '../debug';
import { DataMoshEffect } from './effect';
import { VelocityPass } from './velocity-pass';
export { forgetPreviousMatrix } from './velocity-pass';
/**
* How long a gesture runs before the lost sectors appear, in milliseconds.
*
* A stream does not start dropping packets the instant it starts decoding. The
* gesture opens on a clean smear and the packet loss arrives on top of it,
* which also keeps the frozen blocks from being the first thing the eye reads.
*/
const LOST_SECTOR_DELAY = 300;
/**
* Owns the pipeline and decides, frame by frame, whether the decoder is running
* clean or has had its keyframes taken away.
*/
export class DataMoshManager {
private readonly composer: EffectComposer;
private readonly renderPass: RenderPass;
private readonly effect: DataMoshEffect;
private readonly moshPass: EffectPass;
private readonly fxaaPass: EffectPass;
private readonly velocityPass: VelocityPass;
private readonly feedbackPass: CopyPass;
private readonly feedbackTarget: THREE.WebGLRenderTarget;
private readonly velocitySupported: boolean;
private wasActive = false;
private lastKeyframeTime = 0;
private gestureStartTime = -Infinity;
constructor(
gl: THREE.WebGLRenderer,
scene: THREE.Scene,
private readonly camera: THREE.Camera,
private readonly input: { pressed: boolean; lastRelease: number },
private readonly onCut: () => boolean,
) {
this.velocitySupported =
gl.extensions.has('EXT_color_buffer_half_float') || gl.extensions.has('EXT_color_buffer_float');
if (!this.velocitySupported) console.warn('[DataMosh] No half float target: camera-only motion.');
// The history feeds on its own output. At 8 bit, repeated round trips
// quantise dark linear values until they collapse to black; half float
// preserves them. The composer has to agree: CopyPass re-types the target
// it copies into from the composer's frame buffer type, so an 8-bit
// composer would silently undo the half-float history. Keep the camera-only
// fallback available on hardware that cannot render to a half-float
// attachment.
const frameType = this.velocitySupported ? THREE.HalfFloatType : THREE.UnsignedByteType;
this.composer = new EffectComposer(gl, { frameBufferType: frameType });
this.renderPass = new RenderPass(scene, camera);
this.fxaaPass = new EffectPass(camera, new FXAAEffect());
this.velocityPass = new VelocityPass(scene, camera);
this.feedbackTarget = new THREE.WebGLRenderTarget(1, 1, {
minFilter: THREE.LinearFilter,
magFilter: THREE.LinearFilter,
type: frameType,
depthBuffer: false,
stencilBuffer: false,
});
this.feedbackTarget.texture.name = 'DataMosh.Feedback';
this.feedbackTarget.texture.generateMipmaps = false;
this.effect = new DataMoshEffect(camera);
this.effect.set('pFrame', this.feedbackTarget.texture);
this.effect.set('uVelocity', this.velocityPass.renderTarget.texture);
this.moshPass = new EffectPass(camera, this.effect);
this.feedbackPass = new CopyPass(this.feedbackTarget, false);
// Render, antialias, measure motion, decode, then keep a copy of the result
// as next frame's reference. The last pass is what reaches the screen —
// without it the feedback copy would, and the loop would never close.
this.composer.addPass(this.renderPass);
this.composer.addPass(this.fxaaPass);
this.composer.addPass(this.velocityPass);
this.composer.addPass(this.moshPass);
this.composer.addPass(this.feedbackPass);
this.composer.addPass(new CopyPass(undefined, false));
separateStableDepthTexture(this.composer);
this.updateSettings();
}
render(deltaTime: number): void {
const now = performance.now();
const { pressed, lastRelease } = this.input;
const active = controls.effectEnabled && pressed;
// 0 while held, then climbing to 1 over the fade: how much of the honest
// render is back. 1 means the effect contributes nothing.
const recover =
!controls.effectEnabled || (!pressed && lastRelease < 0)
? 1
: pressed
? 0
: THREE.MathUtils.clamp((now - lastRelease) / Math.max(controls.fadeDuration, 1), 0, 1);
this.effect.set('uRecover', recover);
// A gesture starts one GOP; if the interval is set, later keyframes punch
// through it and partially repair the picture mid-gesture.
const gestureStart = active && !this.wasActive;
const gopRefresh =
active &&
!gestureStart &&
controls.keyframeInterval > 0 &&
now - this.lastKeyframeTime >= controls.keyframeInterval;
if (gestureStart || gopRefresh) this.lastKeyframeTime = now;
if (gestureStart) this.gestureStartTime = now;
this.effect.set('uKeyframe', gopRefresh ? 1 : 0);
// Written here rather than in applySettings, because the delay is a
// property of the gesture and the panel knows nothing about gestures. The
// release does not cancel it: the sectors stay through the recovery fade.
this.effect.set(
'uFrozenBlocks',
now - this.gestureStartTime >= LOST_SECTOR_DELAY ? controls.frozenBlocks : 0,
);
const moshing = recover < 1;
// The overlays draw from the vector field, so they need the pass running
// even when there is nothing to decode.
const overlays = controls.debugMotion || controls.showMotionArrows;
this.velocityPass.enabled =
this.velocitySupported && (moshing || (controls.effectEnabled && overlays));
this.wasActive = active;
// The overlays live inside the effect, so the pass has to run for them even
// when there is nothing to decode.
this.moshPass.enabled =
controls.effectEnabled && (moshing || overlays || controls.showLostSectors);
this.renderPass.needsDepthBlit = this.moshPass.enabled;
this.composer.render(deltaTime);
// The cut happens after the frame is on screen: the new shot is what the
// next frame renders, while the feedback buffer still holds the old one.
// That mismatch, decoded with the incoming shot's vectors, is the whole
// trick — and capturing the post-cut state below keeps the camera jump
// itself from ever becoming a vector.
if ((gestureStart || gopRefresh) && controls.sceneCut && this.onCut()) {
this.camera.updateMatrixWorld(true);
}
this.velocityPass.capturePreviousState();
this.effect.capturePreviousState();
}
setSize(width: number, height: number): void {
this.composer.setSize(Math.max(1, width), Math.max(1, height), false);
this.applyResolutionScale();
}
updateSettings(): void {
this.effect.applySettings();
this.feedbackPass.enabled = controls.effectEnabled;
this.fxaaPass.enabled = controls.antialias;
this.applyResolutionScale();
}
/**
* The history and velocity textures at a fraction of the display resolution:
* coarser, and cheaper. The decode pass itself still runs full size — only
* its sources shrink — so the shader is told their real dimensions through
* uHistoryResolution, or the Catmull-Rom taps would land on the wrong texels.
*
* Measured off the composer's own buffer, never off the CSS size handed to
* setSize. Everything else in the chain is sized in drawing-buffer pixels, so
* on any display with a pixel ratio above 1 a feedback target built from CSS
* pixels is smaller than the picture flowing through it — and the loop then
* downsamples and upsamples once per frame. Bilinear down then up is not the
* identity, so a perfectly still frame dissolves on its own, which reads as
* the effect losing resolution rather than as the bug it is.
*/
private applyResolutionScale(): void {
const scale = THREE.MathUtils.clamp(controls.resolutionScale, 0.1, 1);
const { width, height } = this.composer.inputBuffer;
this.feedbackTarget.setSize(
Math.max(1, Math.round(width * scale)),
Math.max(1, Math.round(height * scale)),
);
this.effect.setHistoryResolution(this.feedbackTarget.width, this.feedbackTarget.height);
this.velocityPass.setResolutionScale(scale);
}
}
/**
* Workaround. The composer builds its stable depth texture by cloning the input
* buffer's, and a cloned three texture shares the same GPU texture — so the
* effect ends up reading the depth attachment it is writing through. WebGL
* rejects the draw, and the mosh silently never appears. Giving the depth
* target its own texture breaks the loop.
*/
const separateStableDepthTexture = (composer: EffectComposer): void => {
const target = (composer as unknown as { depthRenderTarget?: THREE.WebGLRenderTarget | null })
.depthRenderTarget;
const stable = target?.depthTexture;
const shared = composer.inputBuffer.depthTexture;
if (!target || !stable || !shared || stable.source !== shared.source) return;
const replacement = new THREE.DepthTexture(target.width, target.height);
replacement.name = 'DataMosh.StableDepth';
replacement.format = stable.format;
replacement.type = stable.type;
target.depthTexture = replacement;
for (const pass of composer.passes) pass.setDepthTexture(replacement);
};
datamosh-demo/src/datamosh/shaders.ts
/**
* The whole effect is this one fragment shader. It runs the loop a video
* decoder runs on every P-frame:
*
* new frame = warp(previous frame, motion vectors) + residual
*
* A real datamosh happens when the decoder keeps running that loop but never
* receives a fresh keyframe: the motion is right, the picture underneath is
* wrong, and the two drift apart. Here `pFrame` is the previous frame fed back
* from the composer, and `uRecover` is how much of the honest render bleeds
* back in once the gesture ends.
*/
export const mainImageShader = /*glsl*/ `
// pFrame is the feedback buffer (the decoder's reference frame), uVelocity
// the per-pixel motion rendered by VelocityPass. uKeyframe forces a clean
// frame through, uRecover fades the damage away.
uniform sampler2D pFrame; uniform sampler2D uVelocity;
uniform float uTime; uniform float uRecover; uniform float uKeyframe;
// The real size of pFrame (and the velocity buffer). With the resolution
// scale below 1 they are smaller than the pass, and every read that
// positions itself in *their* texels has to use this, not \`resolution\`.
uniform vec2 uHistoryResolution;
// Camera motion, for the pixels the velocity buffer knows nothing about:
// unproject through the depth buffer, reproject through the previous
// camera. uParallax mixes between the full previous matrix (translation
// included) and rotation only.
uniform mat4 uInvViewProjection; uniform mat4 uPrevViewProjection; uniform mat4 uPrevViewProjectionRot;
uniform float uMotionGain; uniform float uParallax;
// Macroblock behaviour. uBlockiness is the master dial: at 0 everything
// below degrades to a smooth per-pixel warp, at 1 it is tiles all the way.
uniform float uBlockSize; uniform float uBlockiness; uniform float uMvPrecision;
uniform float uSkipThreshold; uniform float uMismatch;
uniform float uFrozenBlocks; uniform float uLostLayers; uniform float uLostLife;
uniform float uLostScale; uniform float uLostAspect; uniform float uLostVariance;
uniform float uResidualGain; uniform float uResidualQuant;
uniform float uDebugMotion; uniform float uDebugScale; uniform float uShowLostSectors;
uniform float uDebugField; uniform float uDebugArrows; uniform float uShowMotionArrows;
// Hashes keyed on (block id, slot): every block decides its own fate
// without any per-block state to store.
float hash13(vec3 p3) {
p3 = fract(p3 * 0.1031);
p3 += dot(p3, p3.zyx + 31.32);
return fract((p3.x + p3.y) * p3.z);
}
vec2 hash23(vec3 p3) {
p3 = fract(p3 * vec3(0.1031, 0.1030, 0.0973));
p3 += dot(p3, p3.yzx + 33.33);
return fract((p3.xx + p3.yz) * p3.zy);
}
// Motion for pixels the velocity buffer says nothing about. Walk the pixel
// back to world space through the depth buffer, then forward through the
// previous camera: the gap between the two screen positions is the vector.
vec2 cameraMotionAt(const in vec2 sampleUV) {
vec4 world = uInvViewProjection * vec4(vec3(sampleUV, readDepth(sampleUV)) * 2.0 - 1.0, 1.0);
world /= world.w;
vec4 prevFull = uPrevViewProjection * world;
vec4 prevRot = uPrevViewProjectionRot * world;
if (prevFull.w <= 0.0 || prevRot.w <= 0.0) return vec2(0.0);
return sampleUV - mix((prevRot.xy / prevRot.w) * 0.5 + 0.5,
(prevFull.xy / prevFull.w) * 0.5 + 0.5, uParallax);
}
// Packet loss. Rectangles on a few overlapping grids blink in and out on
// their own clocks; a pixel inside one keeps its old content instead of
// being warped, which is what a decoder does with a block it never got.
// Returns (inside the sector, on its border) — the border is what the
// overlay draws, and costs nothing extra to find on the way.
vec2 lostRegion(const in vec2 uv) {
if (uFrozenBlocks <= 0.0) return vec2(0.0);
for (float fi = 0.0; fi < 4.0; fi++) {
if (fi >= uLostLayers) break;
vec2 scale = vec2(7.0, 5.0) * (1.0 + fi * 1.6) / max(uLostScale, 0.05);
vec2 p = uv * scale;
vec2 cellId = floor(p);
// Each cell holds one rectangle per time slot, and only for the
// first fraction of that slot: that is the blink.
float t = uTime / max(uLostLife, 16.0) + hash13(vec3(cellId, fi + 5.0));
float slot = floor(t);
if (hash13(vec3(cellId, slot * 7.0 + fi)) > uFrozenBlocks) continue;
vec2 centre = 0.2 + hash23(vec3(cellId, slot + fi * 23.0)) * 0.6;
vec2 halfSize = (0.05 + hash23(vec3(cellId, slot + fi * 41.0)) * vec2(0.45, 0.26) * uLostVariance)
* vec2(uLostAspect, 1.0 / max(uLostAspect, 0.05));
vec2 d = abs(fract(p) - centre) - halfSize;
if (any(greaterThan(d, vec2(0.0)))) continue;
if (fract(t) > 0.12 + hash13(vec3(cellId, slot + 61.0)) * 0.45) continue;
// Inside a box the signed distance to its edge is exactly
// max(d.x, d.y); converted to pixels, one macroblock of it is the
// ring of blocks that actually froze along the boundary.
vec2 edge = d / scale * resolution;
return vec2(1.0, step(-max(uBlockSize, 2.0), max(edge.x, edge.y)));
}
return vec2(0.0);
}
// Measured motion where the velocity buffer covered geometry, camera motion
// everywhere else (background, sky, anything not rendered into it).
vec2 rawMotionAt(const in vec2 sampleUV) {
vec4 measured = texture2D(uVelocity, sampleUV);
return mix(cameraMotionAt(sampleUV), measured.xy, step(0.5, measured.a));
}
// Catmull-Rom read of the previous frame, 9 taps instead of 16 by riding
// the hardware bilinear filter. Motion vectors land between texels and the
// result is fed back in as the next reference frame, so a plain bilinear
// read would melt the picture into mush after a few dozen frames.
vec3 sampleHistory(const in vec2 uv) {
vec2 samplePos = uv * uHistoryResolution;
vec2 texPos1 = floor(samplePos - 0.5) + 0.5;
vec2 f = samplePos - texPos1;
vec2 w0 = f * (-0.5 + f * (1.0 - 0.5 * f));
vec2 w2 = f * (0.5 + f * (2.0 - 1.5 * f));
vec2 w3 = f * f * (-0.5 + 0.5 * f);
vec2 w12 = 1.0 + f * f * (-2.5 + 1.5 * f) + w2;
vec2 offset12 = w2 / max(w12, vec2(1e-5));
// The middle tap of each axis sits off-centre so one bilinear fetch
// covers the two inner samples at once.
vec3 px = (texPos1.x + vec3(-1.0, offset12.x, 2.0)) / uHistoryResolution.x;
vec3 py = (texPos1.y + vec3(-1.0, offset12.y, 2.0)) / uHistoryResolution.y;
vec3 wx = vec3(w0.x, w12.x, w3.x);
vec3 wy = vec3(w0.y, w12.y, w3.y);
vec3 result = vec3(0.0);
for (int y = 0; y < 3; y++) {
for (int x = 0; x < 3; x++) {
result += texture2D(pFrame, vec2(px[x], py[y])).rgb * (wx[x] * wy[y]);
}
}
return clamp(result, 0.0, 1.0);
}
// The correction a real stream carries next to the vectors. Compare the
// high frequencies of the honest render against those of the prediction,
// keep the difference only where the render is sharper, and quantise it
// hard: that ringing along the edges is the DCT dead zone showing.
vec3 residualAt(const in vec2 uv, const in vec2 motion, const in vec3 current, const in vec3 predicted) {
vec2 r = vec2(1.5) / resolution;
vec3 lowCurrent = texture2D(inputBuffer, uv + vec2(r.x, 0.0)).rgb;
lowCurrent += texture2D(inputBuffer, uv - vec2(r.x, 0.0)).rgb;
lowCurrent += texture2D(inputBuffer, uv + vec2(0.0, r.y)).rgb;
lowCurrent += texture2D(inputBuffer, uv - vec2(0.0, r.y)).rgb;
// Same 1.5-texel ring, but in the history's own texels.
vec2 rh = vec2(1.5) / uHistoryResolution;
vec2 p = uv - motion;
vec3 lowPredicted = texture2D(pFrame, clamp(p + vec2(rh.x, 0.0), 0.002, 0.998)).rgb;
lowPredicted += texture2D(pFrame, clamp(p - vec2(rh.x, 0.0), 0.002, 0.998)).rgb;
lowPredicted += texture2D(pFrame, clamp(p + vec2(0.0, rh.y), 0.002, 0.998)).rgb;
lowPredicted += texture2D(pFrame, clamp(p - vec2(0.0, rh.y), 0.002, 0.998)).rgb;
float hc = dot(current - lowCurrent * 0.25, vec3(0.299, 0.587, 0.114));
float hp = dot(predicted - lowPredicted * 0.25, vec3(0.299, 0.587, 0.114));
float steps = max(uResidualQuant, 1.0);
return vec3(floor((abs(hc) > abs(hp) ? hc - hp : 0.0) * steps + 0.5) / steps);
}
// A motion vector drawn the way a codec debugger draws it: three strokes,
// the shaft and two barbs folded back from the tip. p is in the cell's own
// frame, x running along the motion.
float sdSegment(const in vec2 p, const in vec2 a, const in vec2 b) {
vec2 pa = p - a, ba = b - a;
return length(pa - ba * clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0));
}
float sdArrow(const in vec2 p, const in float len) {
vec2 tip = vec2(len, 0.0);
vec2 barb = vec2(0.42, 0.42) * len;
return min(sdSegment(p, vec2(-len, 0.0), tip),
min(sdSegment(p, tip, tip - barb), sdSegment(p, tip, tip - barb * vec2(1.0, -1.0))));
}
// Coverage of the cell's arrow at this pixel. Arrows sit on the block grid,
// but thinned to whole blocks until a cell is around 48px: at the default
// 8px macroblock an arrow per block is a smudge. The vector is read at the
// cell centre, which is where a codec stores it.
float arrowAt(const in vec2 uv, const in vec2 blocks, const in vec2 blockId) {
float span = max(floor(48.0 / max(uBlockSize, 1.0) + 0.5), 1.0);
vec2 cellId = floor(blockId / span) * span;
vec2 cellMotion = rawMotionAt((cellId + span * 0.5) / blocks) * uMotionGain * resolution;
float speed = length(cellMotion);
float reach = clamp(speed / max(uDebugScale, 0.01), 0.0, 1.0);
vec2 dir = speed > 1e-4 ? cellMotion / speed : vec2(1.0, 0.0);
// Into the cell's own frame: x along the motion, y across it.
vec2 local = ((uv * blocks - cellId) / span - 0.5) * 2.0;
local = vec2(dot(local, dir), dot(local, vec2(-dir.y, dir.x)));
// One pixel in those units, so the stroke stays one pixel wide whatever
// the macroblock is set to.
float px = 2.0 * blocks.x / (span * resolution.x);
return (1.0 - smoothstep(-px, px, sdArrow(local, 0.15 + reach * 0.7) - px)) * step(0.02, reach);
}
// The arrows drawn over a picture we do not control the palette of, so the
// ink is the plain negative of whatever is underneath.
vec3 withArrows(const in vec3 colour, const in float coverage) {
return mix(colour, 1.0 - colour, coverage);
}
void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor) {
vec2 blocks = max(resolution / max(uBlockSize, 1.0), vec2(1.0));
vec2 blockId = floor(uv * blocks);
vec2 blockUV = (blockId + 0.5) / blocks;
// Nothing to decode: either the effect is at rest, or this is the
// keyframe that repairs the picture. The two overlays still draw here —
// reading the field without holding the trigger is the point of them.
if ((uRecover >= 1.0 && uDebugMotion < 0.5) || uKeyframe > 0.5) {
vec3 clean = inputColor.rgb;
if (uShowMotionArrows > 0.5) clean = withArrows(clean, arrowAt(uv, blocks, blockId));
float border = lostRegion(blockUV).y * uShowLostSectors * uBlockiness;
outputColor = vec4(mix(clean, vec3(1.0), border), inputColor.a);
return;
}
float warp = uDebugMotion > 0.5 ? 1.0 : 1.0 - uRecover;
// One vector per macroblock, read at the block centre. A few blocks
// read their neighbour's vector instead: motion compensation mismatch,
// the tell of a corrupted vector table.
vec2 mvBlockUV = clamp(blockUV + (sign(hash23(vec3(blockId, 29.0)) - 0.5) / blocks)
* (step(hash13(vec3(blockId, 23.0)), uMismatch) * uBlockiness), 0.0, 1.0);
vec2 mvUV = mix(uv, mvBlockUV, uBlockiness);
vec2 motion = rawMotionAt(mvUV) * uMotionGain * warp;
// Codecs store vectors at half or quarter pixel, never at float
// precision. Snapping to that grid is what makes the smear step.
float mvSteps = max(uMvPrecision, 1.0);
vec2 motionPx = motion * resolution;
motion = mix(motionPx, floor(motionPx * mvSteps + 0.5) / mvSteps, uBlockiness) / resolution;
// Two ways a block ends up frozen: too little motion to be worth
// coding (skip), or its data never arrived (lost).
float skip = uSkipThreshold <= 0.0 ? 0.0
: (1.0 - smoothstep(uSkipThreshold * 0.5, uSkipThreshold, length(motion * resolution))) * uBlockiness;
// Debug view: the field as hue and brightness, over the macroblock grid
// the vectors are quantised to, with an arrow per cell on top. Lost
// sectors are left out of it — this view exists to read the vector
// field, and punching holes in it only hides the thing being read.
if (uDebugMotion > 0.5) {
vec2 shown = motion * (1.0 - skip);
vec3 wheel = clamp(abs(mod(atan(shown.y, shown.x) / 6.2831853 * 6.0
+ vec3(0.0, 4.0, 2.0), 6.0) - 3.0) - 1.0, 0.0, 1.0);
float value = clamp(length(shown * resolution) / max(uDebugScale, 0.01), 0.0, 1.0);
vec3 field = mix(vec3(0.12), wheel, step(0.02, value)) * max(value, 0.12);
// With the field off the arrows are left on bare black, which is
// where their direction is easiest to read.
vec3 debug = mix(vec3(0.0), field, uDebugField);
vec2 grid = abs(fract(uv * blocks) - 0.5);
debug = mix(vec3(0.35), debug, 1.0 - step(0.47, max(grid.x, grid.y)));
// Gated on the motion this view shows, so a skipped block draws no
// arrow: the decoder moves nothing there.
float arrow = arrowAt(uv, blocks, blockId) * step(1e-6, length(shown)) * uDebugArrows;
float border = lostRegion(blockUV).y * uShowLostSectors * uBlockiness;
outputColor = vec4(mix(withArrows(debug, arrow), vec3(1.0), border), 1.0);
return;
}
vec2 lost = lostRegion(blockUV);
motion *= 1.0 - max(skip, lost.x * uBlockiness);
// The decode itself: drag the previous frame along the vectors, add the
// residual, then let the honest render fade back in as uRecover rises.
vec3 moshed = length(motion * resolution) < 0.001
? texture2D(pFrame, uv).rgb
: sampleHistory(uv - motion);
moshed += residualAt(uv, motion, inputColor.rgb, moshed) * uResidualGain * (1.0 - skip) * warp;
vec3 decoded = mix(clamp(moshed, 0.0, 1.0), inputColor.rgb, uRecover);
if (uShowMotionArrows > 0.5) decoded = withArrows(decoded, arrowAt(uv, blocks, blockId));
outputColor = vec4(mix(decoded, vec3(1.0), lost.y * uShowLostSectors * uBlockiness), 1.0);
}
`;
/**
* The velocity buffer: where every pixel of the scene was on the previous
* frame, in screen space. Rendered as an override material over the whole
* scene, so it costs one extra depth-only style pass.
*
* Both clip positions travel to the fragment stage because the perspective
* divide has to happen per pixel, not per vertex.
*/
export const velocityVertexShader = /*glsl*/ `
uniform mat4 uPreviousModelMatrix; uniform mat4 uPreviousViewProjection; uniform float uHasPrevious;
varying vec4 vClipCurrent, vClipPrevious;
void main() {
vClipCurrent = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
vClipPrevious = mix(vClipCurrent,
uPreviousViewProjection * uPreviousModelMatrix * vec4(position, 1.0), uHasPrevious);
gl_Position = vClipCurrent;
}
`;
/**
* Alpha 1 marks "this pixel has real geometry behind it" — the effect uses it
* to decide between measured motion and the camera fallback. The clamp keeps a
* vertex crossing the near plane from writing an absurd vector.
*/
export const velocityFragmentShader = /*glsl*/ `
varying vec4 vClipCurrent, vClipPrevious;
void main() {
vec2 velocity = vClipPrevious.w <= 0.0 ? vec2(0.0)
: (vClipCurrent.xy / vClipCurrent.w - vClipPrevious.xy / vClipPrevious.w) * 0.5;
gl_FragColor = vec4(clamp(velocity, -0.25, 0.25), 0.0, 1.0);
}
`;
datamosh-demo/src/datamosh/velocity-pass.ts
import { Pass } from 'postprocessing';
import * as THREE from 'three';
import { velocityFragmentShader, velocityVertexShader } from './shaders';
const PREVIOUS_MATRIX_KEY = '__dataMoshPreviousMatrixWorld';
/**
* Call this on an object the moment it is teleported — a scroll that wraps, a
* particle that respawns. The velocity pass has no way of telling a jump from
* a movement, so it measures the whole distance as one frame of motion, and
* the effect duly drags the picture that far: a visible tear, once per wrap.
* Dropping the stored matrix makes the pass treat the object as new geometry
* and write zero motion for that single frame instead.
*/
export const forgetPreviousMatrix = (object: THREE.Object3D): void => {
delete object.userData[PREVIOUS_MATRIX_KEY];
};
/**
* Renders the scene once more with a material that writes screen-space motion
* instead of colour. Two things move a pixel: the camera, and the object's own
* transform — so each mesh carries its previous world matrix in userData.
*/
export class VelocityPass extends Pass {
readonly renderTarget: THREE.WebGLRenderTarget;
private readonly velocityMaterial: THREE.ShaderMaterial;
private readonly previousViewProjection = new THREE.Matrix4();
private readonly scratchColor = new THREE.Color();
private baseWidth = 1;
private baseHeight = 1;
private resolutionScale = 1;
constructor(scene: THREE.Scene, camera: THREE.Camera) {
super('VelocityPass', scene, camera);
this.needsSwap = false;
// Half float: velocities are signed and small, and nearest filtering keeps
// a block's vector from bleeding into its neighbour.
this.renderTarget = new THREE.WebGLRenderTarget(1, 1, {
minFilter: THREE.NearestFilter,
magFilter: THREE.NearestFilter,
type: THREE.HalfFloatType,
});
this.renderTarget.texture.name = 'DataMosh.Velocity';
this.velocityMaterial = new THREE.ShaderMaterial({
name: 'DataMosh.VelocityMaterial',
vertexShader: velocityVertexShader,
fragmentShader: velocityFragmentShader,
uniforms: {
uPreviousModelMatrix: { value: new THREE.Matrix4() },
uPreviousViewProjection: { value: this.previousViewProjection },
uHasPrevious: { value: 0 },
},
});
// One material draws every mesh, so the per-object matrix has to be swapped
// in here. uniformsNeedUpdate is load bearing: without it three uploads the
// uniforms once and every mesh after the first gets the wrong matrix.
this.velocityMaterial.onBeforeRender = (_r, _s, _c, _g, object) => {
const uniforms = this.velocityMaterial.uniforms;
const previous = object.userData[PREVIOUS_MATRIX_KEY] as THREE.Matrix4 | undefined;
uniforms.uHasPrevious.value = previous ? 1 : 0;
if (previous) (uniforms.uPreviousModelMatrix.value as THREE.Matrix4).copy(previous);
const material = (object as THREE.Mesh).material;
this.velocityMaterial.side = (Array.isArray(material) ? material[0] : material).side;
this.velocityMaterial.uniformsNeedUpdate = true;
};
}
render(renderer: THREE.WebGLRenderer): void {
const scene = this.scene;
const background = scene.background;
const overrideMaterial = scene.overrideMaterial;
const autoUpdateWorld = scene.matrixWorldAutoUpdate;
const clearColor = renderer.getClearColor(this.scratchColor);
const clearAlpha = renderer.getClearAlpha();
// No background (it has no velocity), one material for everything, and no
// matrix update: the render pass already did it this frame, and redoing it
// now would overwrite what we are about to compare against.
scene.background = null;
scene.overrideMaterial = this.velocityMaterial;
scene.matrixWorldAutoUpdate = false;
// Clearing to alpha 0 is what marks "no geometry here" for the effect.
renderer.setRenderTarget(this.renderTarget);
renderer.setClearColor(0x000000, 0);
renderer.clear(true, true, false);
renderer.render(scene, this.camera);
renderer.setClearColor(clearColor, clearAlpha);
scene.matrixWorldAutoUpdate = autoUpdateWorld;
scene.overrideMaterial = overrideMaterial;
scene.background = background;
}
/** End of frame: today's matrices become tomorrow's "previous". */
capturePreviousState(): void {
this.previousViewProjection.multiplyMatrices(
this.camera.projectionMatrix,
this.camera.matrixWorldInverse,
);
this.scene.traverse((object) => {
if (!(object as THREE.Mesh).isMesh) return;
const previous = object.userData[PREVIOUS_MATRIX_KEY] as THREE.Matrix4 | undefined;
if (previous) previous.copy(object.matrixWorld);
else object.userData[PREVIOUS_MATRIX_KEY] = object.matrixWorld.clone();
});
}
// The composer sizes passes in drawing-buffer pixels; the scale slider then
// shrinks the buffer on top of that, which is why both are kept.
setResolutionScale(scale: number): void {
this.resolutionScale = scale;
this.setSize(this.baseWidth, this.baseHeight);
}
setSize(width: number, height: number): void {
this.baseWidth = Math.max(1, width);
this.baseHeight = Math.max(1, height);
this.renderTarget.setSize(
Math.max(1, Math.round(this.baseWidth * this.resolutionScale)),
Math.max(1, Math.round(this.baseHeight * this.resolutionScale)),
);
}
}
datamosh-demo/src/debug.ts
import { createDriftpane } from '@niccolofanton/driftpane';
import { Pane } from 'tweakpane';
/**
* The pipeline reads these inside the render loop, so a slider reaches the
* shader on the same frame it is moved. The defaults are the ones the effect
* was tuned against.
*/
export const controls = {
// What the decoder does and where its motion comes from.
effectEnabled: true,
/** Holds the trigger down without a button held, to sit inside a gesture. */
latch: false,
sceneCut: true,
motionGain: 1.5,
parallax: 1,
fadeDuration: 370,
/** 0 = one keyframe for the whole gesture (infinite GOP). */
keyframeInterval: 0,
// The block grid and the ways a block can go wrong: quantised, skipped,
// stolen from a neighbour, or never delivered at all.
blockSize: 8,
/** Off = continuous per pixel warp: a liquid smear instead of sliding tiles. */
macroblocks: true,
blockiness: 1,
mvPrecision: 2,
skipThreshold: 0,
frozenBlocks: 1,
lostLayers: 4,
lostLife: 50,
lostScale: 2.65,
lostAspect: 0.75,
lostVariance: 1,
mismatch: 0,
// The correction added on top of the prediction, and how coarsely it is quantised.
/** Carried at ten times its real value: the useful range sits under 0.05. */
residualGain: 4,
residualQuant: 14,
// Buffer resolution and the two views: antialiased render, or raw vectors.
resolutionScale: 1,
antialias: true,
debugMotion: false,
/** What the vector view draws: the hue field, the arrows, or both. */
debugView: 'both' as 'both' | 'field' | 'arrows',
debugScale: 8,
/** Draws the vector arrows over the picture itself, in negative. */
showMotionArrows: false,
/** Outlines the packet-loss rectangles in white, and changes nothing else. */
showLostSectors: false,
};
/** Builds the panel. `onChange` fires after any control changes. */
export const createPane = (onChange: () => void): void => {
const pane = new Pane({ title: 'Data Mosh' });
const mosh = pane.addFolder({ title: '🎞️ Datamosh' });
mosh.addBinding(controls, 'effectEnabled', { label: 'Enabled' });
mosh.addBinding(controls, 'latch', { label: 'Hold Trigger' });
mosh.addBinding(controls, 'sceneCut', { label: 'Cut On Trigger' });
mosh.addBinding(controls, 'motionGain', { label: 'Motion Gain', min: 0, max: 30, step: 0.5 });
mosh.addBinding(controls, 'parallax', { label: 'Parallax (Background)', min: 0, max: 4, step: 0.05 });
mosh.addBinding(controls, 'fadeDuration', { label: 'Recovery (ms)', min: 1, max: 2000, step: 1 });
mosh.addBinding(controls, 'keyframeInterval', { label: 'Keyframe Every (ms)', min: 0, max: 3000, step: 10 });
const blocks = pane.addFolder({ title: '🧱 Macroblocks' });
blocks.addBinding(controls, 'blockSize', { label: 'Macroblock (px)', min: 4, max: 64, step: 1 });
blocks.addBinding(controls, 'macroblocks', { label: 'Macroblocks' });
blocks.addBinding(controls, 'blockiness', { label: 'Block Quantise', min: 0, max: 1, step: 0.01 });
blocks.addBinding(controls, 'mvPrecision', { label: 'Vector Precision', options: { 'Full pel': 1, 'Half pel (MPEG-4)': 2, 'Quarter pel (H.264)': 4 } });
blocks.addBinding(controls, 'skipThreshold', { label: 'Skip Below (px)', min: 0, max: 8, step: 0.05 });
blocks.addBinding(controls, 'frozenBlocks', { label: 'Lost: Density', min: 0, max: 1, step: 0.01 });
blocks.addBinding(controls, 'lostLayers', { label: 'Lost: Layers', min: 1, max: 4, step: 1 });
blocks.addBinding(controls, 'lostLife', { label: 'Lost: Refresh (ms)', min: 40, max: 8000, step: 5 });
blocks.addBinding(controls, 'lostScale', { label: 'Lost: Size', min: 0.2, max: 8, step: 0.05 });
blocks.addBinding(controls, 'lostAspect', { label: 'Lost: Aspect', min: 0.2, max: 5, step: 0.05 });
blocks.addBinding(controls, 'lostVariance', { label: 'Lost: Variance', min: 0, max: 1, step: 0.05 });
blocks.addBinding(controls, 'mismatch', { label: 'MC Mismatch', min: 0, max: 0.5, step: 0.01 });
const residual = pane.addFolder({ title: '🩸 Residual' });
residual.addBinding(controls, 'residualGain', { label: 'Residual Gain (x10)', min: 0, max: 30, step: 0.01 });
residual.addBinding(controls, 'residualQuant', { label: 'Quantiser Steps', min: 2, max: 64, step: 1 });
const pipeline = pane.addFolder({ title: '🔬 Pipeline', expanded: false });
pipeline.addBinding(controls, 'resolutionScale', { label: 'History/velocity texture scale', min: 0.2, max: 1, step: 0.05 });
pipeline.addBinding(controls, 'antialias', { label: 'Antialias (FXAA)' });
pipeline.addBinding(controls, 'debugMotion', { label: 'Show Motion Vectors' });
pipeline.addBinding(controls, 'debugView', { label: 'Vector View', options: { 'Colour + arrows': 'both', 'Colour only': 'field', 'Arrows only': 'arrows' } });
pipeline.addBinding(controls, 'debugScale', { label: 'Vector Scale (px)', min: 1, max: 40, step: 0.5 });
pipeline.addBinding(controls, 'showMotionArrows', { label: 'Arrows Over Picture' });
pipeline.addBinding(controls, 'showLostSectors', { label: 'Show Lost Sectors' });
// One listener on the root: Tweakpane bubbles every binding's change up to the
// pane, and has already written the new value into `controls` by then.
pane.on('change', onChange);
// Persistence, dragging and presets, added once the pane is fully built.
// A restore goes through Tweakpane's `importState()`, which re-fires the
// binding `change` handlers for every value that actually differs, so
// `onChange` above runs on its own and Driftpane refreshes the widgets - no
// re-apply pass is needed here.
createDriftpane(pane, { storageNamespace: 'datamosh-demo', width: 300 });
};
// When this module is replaced, the pane it built is orphaned on screen.
import.meta.hot?.dispose(() => {
document.querySelector('.tp-dfwv')?.remove();
});
datamosh-demo/src/main.ts
import '@niccolofanton/driftpane/theme.css';
import * as THREE from 'three';
import { DataMoshManager } from './datamosh';
import { controls, createPane } from './debug';
import { SHOTS } from './scenes';
/**
* `?demo=true` is the showcase cut: two shots instead of six, and no control
* panel. It is what gets embedded or screen-recorded, where a Tweakpane column
* down the right-hand side is noise and four of the shots are there to make a
* point about *kinds* of motion that only the article needs made.
*
* Nothing about the pipeline changes - this only decides what is on stage.
*/
const showcase = new URLSearchParams(location.search).get('demo') === 'true';
/**
* Indices into `SHOTS`: the torus knot and the sliding checker floor.
*
* They are the pair that carries the effect on its own. The knot is a solid
* body tumbling in place, so the smear stays where the eye already is; the
* floor runs long vectors at the bottom of the frame and sub-pixel ones at the
* horizon, so a single shot shows the whole range the decoder has to cope with.
* And cutting between them is a hard cut - dark to light, near to deep - which
* is exactly the mismatch the effect feeds on.
*/
const SHOWCASE = [0, 5];
const shots = showcase ? SHOWCASE.map((i) => SHOTS[i]) : SHOTS;
// Renderer and canvas. The composer takes over clearing, so nothing else here
// touches the framebuffer.
const renderer = new THREE.WebGLRenderer({ powerPreference: 'high-performance' });
document.body.append(renderer.domElement);
// One scene holding every shot at once: switching is a visibility flip, which
// keeps the geometry warm and the cut instant.
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(55, 1, 0.1, 100);
// One fog instance for the whole run, recoloured and re-dialled on the cut.
// Attached up front because adding it later would recompile every material;
// a density of zero is the shots that want to see all the way out.
const fog = new THREE.FogExp2(0x000000, 0);
scene.fog = fog;
const light = new THREE.DirectionalLight(0xffffff, 2.4);
light.position.set(3, 4, 5);
scene.add(new THREE.AmbientLight(0xffffff, 1.1), light, ...shots.map((shot) => shot.object));
// The cut. Called once at boot, and then by the manager on every gesture: the
// new shot lands in the frame after the one already on screen, which is exactly
// the mismatch the effect feeds on.
let index = -1;
const cut = (): true => {
index = (index + 1) % shots.length;
const shot = shots[index];
for (const [i, other] of shots.entries()) other.object.visible = i === index;
scene.background = shot.background;
// Same colour as the background, so what the fog swallows lands exactly on
// the clear colour and the far edge of a shot stops existing.
fog.color.copy(shot.background);
fog.density = shot.fog;
camera.position.set(...shot.camera);
camera.lookAt(0, 0, 0);
camera.updateMatrixWorld(true);
return true;
};
// Trigger state. Pointer, spacebar and the latch toggle all feed one flag the
// manager polls; `lastRelease` starts the recovery fade. Tracking the two
// sources apart is what stops a released key from cancelling a held mouse.
const input = { pressed: false, lastRelease: -1 };
const held = { pointer: false, keyboard: false };
const sync = (source?: keyof typeof held, down = false) => {
if (source) held[source] = down;
const pressed = controls.latch || held.pointer || held.keyboard;
if (pressed === input.pressed) return;
input.pressed = pressed;
input.lastRelease = pressed ? -1 : performance.now();
};
// Press on the canvas only, so dragging a slider does not trigger the effect —
// but release anywhere, including outside the window. pointercancel covers a
// touch stolen by a system gesture, blur an alt-tab with the key still down:
// either would otherwise leave the effect stuck on.
renderer.domElement.addEventListener('pointerdown', (e) => { if (e.button === 0) sync('pointer', true); });
for (const type of ['pointerup', 'pointercancel']) addEventListener(type, () => sync('pointer'));
addEventListener('keydown', (e) => { if (e.code === 'Space' && !e.repeat) sync('keyboard', true); });
addEventListener('keyup', (e) => { if (e.code === 'Space') sync('keyboard'); });
addEventListener('blur', () => { held.pointer = held.keyboard = false; sync(); });
cut();
const manager = new DataMoshManager(renderer, scene, camera, input, cut);
// A panel change can flip the trigger (the latch) as well as any shader value,
// so both are refreshed on the same callback. In the showcase there is no
// panel, and therefore nothing that will ever call back: the defaults the
// effect was tuned against are what runs.
if (!showcase) createPane(() => { sync(); manager.updateSettings(); });
// setPixelRatio has to stay here: the composer only resizes the renderer when
// the CSS size changes, so moving the window to a different display would
// otherwise leave the buffers at the old density.
const resize = () => {
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
camera.aspect = innerWidth / innerHeight;
camera.updateProjectionMatrix();
manager.setSize(innerWidth, innerHeight);
};
addEventListener('resize', resize);
resize();
// Every shot animates, visible or not: the cut has to land mid-movement, or
// there would be no motion for the decoder to smear the old picture along.
const clock = new THREE.Clock();
renderer.setAnimationLoop(() => {
const delta = clock.getDelta();
for (const shot of shots) shot.update(delta);
manager.render(delta);
});
datamosh-demo/src/scenes.ts
import * as THREE from 'three';
import { forgetPreviousMatrix } from './datamosh';
/**
* The shots the effect cuts between. Every one of them is a different *shape*
* of motion field, because that is the only thing the decoder ever sees: a
* translation, a zoom, a rotation, and vectors that disagree with their
* neighbours all smear in completely different ways. Light and dark alternate
* down the list so that any cut also inverts the picture.
*/
export type Shot = {
background: THREE.Color; camera: [number, number, number];
/** Exponential fog density, in the shot's own background colour. 0 = none. */
fog: number;
object: THREE.Object3D; update: (delta: number) => void;
};
const knot = new THREE.Mesh(
// 0.85 and 0.306: the knot at 85%, tube included, so it keeps its
// proportions and simply sits further inside the frame.
new THREE.TorusKnotGeometry(0.85, 0.306, 180, 32),
new THREE.MeshStandardMaterial({ color: '#e9e3d4', roughness: 0.35, metalness: 0.15 }),
);
// The swarm. Positions are spread by multiplying the index with primes and
// keeping the remainder: scattered enough to read as random, and identical on
// every run, which is what makes the effect reproducible.
const COUNT = 50;
const SPAN = 40;
const SPEED = 3.2;
const boxes = new THREE.Group();
// The tilt is baked into the shared geometry: 50 meshes, one buffer, no
// per-object rotation to update.
const boxGeometry = new THREE.BoxGeometry().rotateY(0.8).rotateX(0.4);
const boxMaterial = new THREE.MeshStandardMaterial({ color: '#1b1b22', roughness: 0.5 });
const spread = (i: number, prime: number) => ((i * prime) % 1000) / 1000;
for (let i = 0; i < COUNT; i++) {
const box = new THREE.Mesh(boxGeometry, boxMaterial);
box.position.set(-SPAN / 2 + spread(i, 7919) * SPAN, -2 + spread(i, 6151) * 4, -6 + spread(i, 3571) * 8);
box.scale.setScalar(0.4 + spread(i, 2749) * 0.7);
boxes.add(box);
}
// The tunnel. Rings coming straight down the barrel, so the vectors point out
// of the centre of the frame instead of across it: the picture is torn open
// from the middle rather than dragged sideways.
const RING_COUNT = 18;
const RING_GAP = 1.7;
const RING_SPEED = 7;
const RING_END = 5.5;
const tunnel = new THREE.Group();
const ringGeometry = new THREE.TorusGeometry(2.3, 0.13, 8, 56);
const ringMaterial = new THREE.MeshStandardMaterial({ color: '#8fd8ff', roughness: 0.25, metalness: 0.5 });
for (let i = 0; i < RING_COUNT; i++) {
const ring = new THREE.Mesh(ringGeometry, ringMaterial);
// The twist stops the rings from reading as one smooth pipe, which would
// leave the block matcher nothing to lock onto along the wall.
ring.position.z = RING_END - i * RING_GAP;
ring.rotation.z = i * 0.35;
tunnel.add(ring);
}
// The wave. Neighbouring columns ride opposite phases, so half the blocks in
// any neighbourhood move up while the other half move down — the worst case for
// block matching, and where the mismatch dial finally has something to bite on.
const WAVE_COLS = 19;
const WAVE_ROWS = 11;
const WAVE_PITCH = 0.82;
const WAVE_AMPLITUDE = 0.62;
const wave = new THREE.Group();
const cubeGeometry = new THREE.BoxGeometry(0.5, 0.5, 0.5);
const cubeMaterial = new THREE.MeshStandardMaterial({ color: '#23232b', roughness: 0.55 });
let wavePhase = 0;
for (let i = 0; i < WAVE_COLS * WAVE_ROWS; i++) {
const cube = new THREE.Mesh(cubeGeometry, cubeMaterial);
cube.position.x = ((i % WAVE_COLS) - (WAVE_COLS - 1) / 2) * WAVE_PITCH;
wave.add(cube);
}
// The vortex. One flat fan turning in the plane of the screen: the vectors are
// tangential and grow with the radius, so the smear curls instead of running
// off in a straight line.
const BLADES = 11;
const BLADE_RADIUS = 1.75;
const vortex = new THREE.Group();
const bladeGeometry = new THREE.BoxGeometry(2.7, 0.24, 0.06);
const bladeMaterial = new THREE.MeshStandardMaterial({ color: '#ff9a52', roughness: 0.4, metalness: 0.2 });
for (let i = 0; i < BLADES; i++) {
const angle = (i / BLADES) * Math.PI * 2;
const blade = new THREE.Mesh(bladeGeometry, bladeMaterial);
blade.position.set(Math.cos(angle) * BLADE_RADIUS, Math.sin(angle) * BLADE_RADIUS, i * 0.02);
blade.rotation.z = angle;
vortex.add(blade);
}
// The floor. A hard checker sliding away in perspective: the vectors are long
// at the bottom of the frame and sub-pixel at the horizon, so one shot holds
// every magnitude at once. The 2x2 texture is the cheapest way to hand the
// residual coder the high frequencies it exists to correct.
const FLOOR_SPEED = 5;
/** Two cells of the pattern, in world units: the wrap distance. */
const FLOOR_PERIOD = 4;
const checker = new THREE.DataTexture(
new Uint8Array([234, 231, 222, 255, 26, 26, 32, 255, 26, 26, 32, 255, 234, 231, 222, 255]), 2, 2,
);
checker.needsUpdate = true;
checker.wrapS = checker.wrapT = THREE.RepeatWrapping;
checker.repeat.setScalar(30);
checker.colorSpace = THREE.SRGBColorSpace;
// Mipmaps on a 2x2 checker collapse to flat grey in the distance, which is
// exactly right: the alternative is a shimmering moiré that the velocity pass
// would happily measure as real motion.
checker.generateMipmaps = true;
checker.minFilter = THREE.LinearMipmapLinearFilter;
checker.magFilter = THREE.NearestFilter;
const floor = new THREE.Mesh(
new THREE.PlaneGeometry(120, 120).rotateX(-Math.PI / 2),
new THREE.MeshStandardMaterial({ map: checker, roughness: 0.8 }),
);
floor.position.y = -1.4;
export const SHOTS: Shot[] = [
{
background: new THREE.Color('#0b0b0f'),
camera: [0, 0, 4.2],
fog: 0,
object: knot,
update: (delta) => knot.rotation.set(knot.rotation.x + delta * 0.55, knot.rotation.y + delta * 0.9, 0),
},
{
background: new THREE.Color('#d9d4c8'),
camera: [0, 0.6, 6],
// The swarm wraps well outside the frame, so there is nothing to hide.
fog: 0,
object: boxes,
// Each box wraps on its own, so the swarm never lines up into a visible seam.
update: (delta) => boxes.children.forEach((box) => {
box.position.x -= SPEED * delta;
if (box.position.x < -SPAN / 2) {
box.position.x += SPAN;
forgetPreviousMatrix(box);
}
}),
},
{
background: new THREE.Color('#04060d'),
camera: [0, 0, 6],
// Heavy: the rings recycle 31 units out, and the fog has to be shut well
// before that or the new one is seen arriving. It also gives the tube its
// depth — on a near-black background the falloff reads as distance.
fog: 0.1,
object: tunnel,
update: (delta) => tunnel.children.forEach((ring) => {
ring.position.z += RING_SPEED * delta;
if (ring.position.z > RING_END) {
ring.position.z -= RING_COUNT * RING_GAP;
forgetPreviousMatrix(ring);
}
}),
},
{
background: new THREE.Color('#ccd3d7'),
camera: [0, 0, 9],
fog: 0,
object: wave,
update: (delta) => {
wavePhase += delta;
wave.children.forEach((cube, i) => {
const row = Math.floor(i / WAVE_COLS);
cube.position.y = (row - (WAVE_ROWS - 1) / 2) * WAVE_PITCH
+ Math.sin(wavePhase * 2.6 + (i % WAVE_COLS) * 2.1) * WAVE_AMPLITUDE;
});
},
},
{
background: new THREE.Color('#130a14'),
camera: [0, 0, 5.5],
fog: 0,
object: vortex,
update: (delta) => { vortex.rotation.z += delta * 1.5; },
},
{
background: new THREE.Color('#b6c0c6'),
camera: [0, 1.1, 5],
// Buries the far edge of the plane well before it is reached. Without it
// the horizon *is* that edge, and it visibly hops every time the floor
// wraps back on itself.
fog: 0.05,
object: floor,
// Wrapping on the pattern period rather than on the plane keeps the jump
// out of the picture: the checker lands exactly back on itself. It is still
// a teleport as far as the velocity pass is concerned, hence the forget.
update: (delta) => {
floor.position.z += FLOOR_SPEED * delta;
if (floor.position.z > FLOOR_PERIOD) {
floor.position.z -= FLOOR_PERIOD;
forgetPreviousMatrix(floor);
}
},
},
];
datamosh-demo/vite.config.ts
import { defineConfig } from 'vite';
export default defineConfig({
// Relative asset URLs: Codrops deploys the demo into a dedicated directory on
// their server, not at a host root, so `/assets/...` would 404 there.
base: './',
// three.js alone is past Rollup's default 500 kB warning threshold.
build: { chunkSizeWarningLimit: 1500 },
});
datamosh-fullproject/archive/camera-sim/optics-sensor.ts
import { BlendFunction, Effect } from "postprocessing";
import * as THREE from "three";
import { opticsSensorShader } from "./shaders/optics-sensor";
import { sceneCut } from "@/state/scene-cut";
export interface OpticsSensorSettings {
/** k1 of the radial distortion. Positive is barrel, the wide lens look. */
barrel: number;
/** Lateral chromatic aberration at the corner, in pixels. */
chromaticAberration: number;
/** Rolling shutter strength, scaled by how fast the camera is panning. */
rollingShutter: number;
/** How hard the auto exposure hunts. 0 = perfectly exposed, 1 = never settles. */
autoExposure: number;
/** How far the auto white balance drifts. */
whiteBalanceDrift: number;
/** Base noise at zero gain. */
noise: number;
/** Share of the noise burnt into the sensor rather than changing per frame. */
fixedPattern: number;
}
/** Stiffness of the exposure loop. */
const EXPOSURE_STIFFNESS = 26;
/**
* Damping below 1 is what produces the overshoot, and the overshoot is the
* whole point: an auto exposure that eased perfectly into place would look like
* a colour grade. A camera does not settle, it hunts.
*/
const EXPOSURE_DAMPING = 0.86;
/**
* Optics and sensor: everything that happens to the light before the encoder.
*
* The automatics are simulated rather than measured from the frame. Measuring
* would mean reading the framebuffer back every frame, which stalls the
* pipeline for an effect that does not need to be accurate - what the eye
* recognises is the *shape* of the response (lag, overshoot, settle), not
* whether the target matched the true scene luminance. The loop is driven by
* the thing that would really disturb it, a cut, plus a slow wander in between.
*/
export class OpticsSensorEffect extends Effect {
private readonly camera: THREE.Camera;
// Exposure loop: a damped spring, which gives lag and overshoot for free.
private exposure = 1;
private exposureVelocity = 0;
private exposureTarget = 1;
private readonly whiteBalance = new THREE.Vector3(1, 1, 1);
private readonly whiteBalanceTarget = new THREE.Vector3(1, 1, 1);
private elapsed = 0;
private hunt = 0;
private drift = 0;
private rollingShutterAmount = 0;
// Camera state, for the rolling shutter.
private readonly previousForward = new THREE.Vector3();
private readonly forward = new THREE.Vector3();
private hasPreviousForward = false;
private yawRate = 0;
private readonly unsubscribeCut: () => void;
constructor(camera: THREE.Camera, settings: OpticsSensorSettings) {
super("OpticsSensor", opticsSensorShader, {
blendFunction: BlendFunction.SRC,
uniforms: new Map<string, THREE.Uniform>([
["uTime", new THREE.Uniform(0)],
["uBarrel", new THREE.Uniform(settings.barrel)],
["uChromAb", new THREE.Uniform(settings.chromaticAberration)],
["uSkew", new THREE.Uniform(0)],
["uExposure", new THREE.Uniform(1)],
["uWhiteBalance", new THREE.Uniform(new THREE.Vector3(1, 1, 1))],
["uGain", new THREE.Uniform(0)],
["uNoise", new THREE.Uniform(settings.noise)],
["uFixedPattern", new THREE.Uniform(settings.fixedPattern)],
]),
});
this.camera = camera;
// A cut is the one moment a real auto exposure is guaranteed to be wrong:
// the scene it had settled on is gone. Kicking the loop here is what
// produces the dip and recover after every jump.
this.unsubscribeCut = sceneCut.subscribe(() => {
this.exposureTarget = 1 + (Math.random() - 0.5) * 1.5 * this.hunt;
this.whiteBalanceTarget.set(
1 + (Math.random() - 0.5) * 0.18 * this.drift,
1,
1 + (Math.random() - 0.5) * 0.18 * this.drift,
);
});
}
update(
_renderer: THREE.WebGLRenderer,
_inputBuffer: THREE.WebGLRenderTarget,
deltaTime = 1 / 60,
): void {
const dt = THREE.MathUtils.clamp(deltaTime, 1 / 240, 1 / 15);
this.elapsed += dt;
this.updateExposure(dt);
this.updateWhiteBalance(dt);
this.updateRollingShutter(dt);
this.uniforms.get("uTime")!.value = this.elapsed;
}
private updateExposure(dt: number): void {
const wander =
Math.sin(this.elapsed * 0.37) * 0.06 +
Math.sin(this.elapsed * 0.13 + 1.7) * 0.04;
const target = this.exposureTarget + wander * this.hunt;
this.exposureVelocity += (target - this.exposure) * EXPOSURE_STIFFNESS * dt;
this.exposureVelocity *= Math.pow(EXPOSURE_DAMPING, dt * 60);
this.exposure += this.exposureVelocity * dt;
this.exposure = THREE.MathUtils.clamp(this.exposure, 0.25, 2.4);
this.uniforms.get("uExposure")!.value = this.exposure;
// AGC: opening up past neutral means the amplifier is working, and an
// amplifier that works adds noise. Only brightening counts - stopping down
// does not take a sensor below its own floor.
this.uniforms.get("uGain")!.value = THREE.MathUtils.clamp(
(this.exposure - 1) * 1.4,
0,
1,
);
}
private updateWhiteBalance(dt: number): void {
// Slow, and never quite arriving: an auto white balance is always a few
// frames behind the light it is looking at.
const k = 1 - Math.exp(-dt / 0.9);
this.whiteBalance.lerp(this.whiteBalanceTarget, k);
(this.uniforms.get("uWhiteBalance")!.value as THREE.Vector3).copy(
this.whiteBalance,
);
}
/**
* Rolling shutter skew, driven by how fast the camera is turning.
*
* The lean is proportional to the horizontal angular rate because that is
* literally what it measures: how far the scene travelled while the sensor
* was being read from top to bottom.
*/
private updateRollingShutter(dt: number): void {
this.camera.getWorldDirection(this.forward);
if (this.hasPreviousForward) {
const rate =
(this.forward.x - this.previousForward.x) * -this.previousForward.z +
(this.forward.z - this.previousForward.z) * this.previousForward.x;
// Smoothed: the raw per frame delta is noisy enough to make the skew
// flicker instead of lean.
const k = 1 - Math.exp(-dt / 0.08);
this.yawRate += (rate / dt - this.yawRate) * k;
}
this.previousForward.copy(this.forward);
this.hasPreviousForward = true;
this.uniforms.get("uSkew")!.value =
THREE.MathUtils.clamp(this.yawRate * 0.05, -0.35, 0.35) *
this.rollingShutterAmount;
}
/**
* The purely geometric part of this stage, for the passes downstream that
* have to sample buffers rendered *before* the lens bent the picture.
*
* Read after the frame has been drawn, so the skew is the one this stage
* actually used; a consumer applying it on the following frame is a frame
* behind on a value that is already smoothed over about 80 ms.
*/
get barrel(): number {
return this.uniforms.get("uBarrel")!.value as number;
}
get skew(): number {
return this.uniforms.get("uSkew")!.value as number;
}
applySettings(settings: OpticsSensorSettings): void {
this.uniforms.get("uBarrel")!.value = settings.barrel;
this.uniforms.get("uChromAb")!.value = settings.chromaticAberration;
this.uniforms.get("uNoise")!.value = settings.noise;
this.uniforms.get("uFixedPattern")!.value = settings.fixedPattern;
this.rollingShutterAmount = settings.rollingShutter;
this.hunt = settings.autoExposure;
this.drift = settings.whiteBalanceDrift;
// With the automatics turned off the loop has to be released back to
// neutral, or the last value it happened to hold would stay burnt in.
if (this.hunt <= 0) this.exposureTarget = 1;
if (this.drift <= 0) this.whiteBalanceTarget.set(1, 1, 1);
}
dispose(): void {
this.unsubscribeCut();
super.dispose();
}
}
datamosh-fullproject/archive/camera-sim/settings.ts
import { OpticsSensorSettings } from "./optics-sensor";
import { SignalSettings } from "./signal";
/**
* The camera the scene is shot on.
*
* There is one, not a menu of them. The goal is the look a cheap wide-angle
* body-worn camera gives a scene - the lens, the sensor, the automatics, the
* compression - not a museum of recording formats. Anything that only says
* "this is a recording" rather than changing how the image itself behaves
* (burnt-in timecode, REC dots, tape damage) is deliberately absent: it is set
* dressing, and set dressing is not an effect.
*/
export interface CameraSettings {
enabled: boolean;
optics: OpticsSensorSettings;
signal: SignalSettings;
bloom: {
intensity: number;
/** Luminance a pixel has to reach before it starts to glow. */
threshold: number;
smoothing: number;
};
/** Vertical field of view in degrees. Wide is a frustum, not a distortion. */
fov: number;
}
/**
* Dialled in on the running scene rather than derived from a spec sheet, which
* is why some of it reads against the physical story the rest of the code
* tells. Worth naming the departures rather than quietly leaving them:
*
* rolling shutter and AE hunting are off. Both are motion artifacts, and this
* pipeline already has a violent one - the datamosh. Two things bending the
* picture at once read as a broken renderer rather than as a camera.
*
* the grade is close to neutral: no lifted blacks, no held whites, contrast
* barely above 1. The washed look those produce fights the effect, which
* needs contrast to have something to tear along, so it is carried by the
* vignette and the trail instead.
*
* the trail is long, at 0.98, and weighted entirely to highlights. That is
* what carries the sense of a cheap sensor here, and it happens to be the one
* camera artifact that compounds with a melt rather than competing with it.
*/
export const DEFAULT_CAMERA: CameraSettings = {
enabled: false,
fov: 79,
optics: {
barrel: 0.6,
chromaticAberration: 10,
rollingShutter: 0,
autoExposure: 0,
whiteBalanceDrift: 0.3,
noise: 0.04,
fixedPattern: 0.2,
},
signal: {
chromaBleed: 0,
chromaDelay: 8,
persistence: 0.98,
persistenceBias: 1,
lift: 0,
liftTint: "#a7ddff",
shoulder: 0,
contrast: 1.07,
saturation: 1,
tint: "#f5f7f6",
vignette: 0.43,
grain: 0.02,
},
bloom: {
intensity: 0.65,
threshold: 0.75,
smoothing: 0.3,
},
};
datamosh-fullproject/archive/camera-sim/shaders/lens-remap.ts
/**
* The geometric half of the lens, shared by everything that has to agree about
* where a pixel is.
*
* It lives on its own because two unrelated stages need the *same* mapping and
* a copy that drifts is worse than no copy at all. The optics pass uses it to
* draw the picture; the datamosh uses it to look up the scene buffers - the
* velocity field and the depth - which are rendered through the plain
* projection and therefore live in a different set of coordinates from the
* image they are supposed to describe.
*
* Without that second use the effect is subtly but visibly wrong. The output
* pixel at the corner of a barrel-distorted frame is showing scene content from
* some way inside the frame, while the motion vector read at that same corner
* belongs to the content that would have been there without the lens. The two
* disagree most exactly where the distortion is strongest, so the melt drags
* the edges of the picture in directions nothing on screen is moving in.
*
* Requires `aspect` in scope, plus `uBarrel` and `uSkew` uniforms. Both zero is
* exactly the identity - `(1 + 0) / (1 + 0)` and no shear - so a stage that
* simply passes zeros needs no branch to switch the lens off.
*/
export const lensRemapGlsl = /*glsl*/ `
/**
* Barrel distortion plus the shutter's read order, as one uv remap.
*
* The two compose rather than stack: the rows are read at different times
* *through* the lens, so a pan skews the already-distorted image.
*
* The distortion is normalised against the corner so it redistributes the
* frame instead of shrinking it. Without that the picture pulls away from
* the edges and leaves a black border, which is the opposite of what a wide
* lens does - a wide lens fills the frame, it does not float inside it.
*/
vec2 lensRemap(vec2 uv) {
vec2 centred = uv - 0.5;
// Aspect corrected so the distortion stays circular rather than
// following the shape of the viewport.
centred.x *= aspect;
float r2 = dot(centred, centred);
float cornerR2 = 0.25 * (aspect * aspect + 1.0);
centred *= (1.0 + uBarrel * r2) / (1.0 + uBarrel * cornerR2);
centred.x /= aspect;
// Rolling shutter: the last row is read later than the first, so during
// a horizontal pan the bottom of the frame lags behind the top and the
// image leans over.
centred.x += uSkew * centred.y;
return centred + 0.5;
}
`;
datamosh-fullproject/archive/camera-sim/shaders/optics-sensor.ts
import { lensRemapGlsl } from "./lens-remap";
/**
* Fragment shader of the optics + sensor stage.
*
* This is the *first* half of the camera chain and it runs BEFORE the datamosh,
* which is the whole point of splitting it in two: everything here happens
* between the light and the encoder, so the codec downstream gets to compress
* an image that is already dirty. Grain a codec has chewed on reads as a
* sensor; grain laid on top of a finished frame reads as a filter.
*
* Order inside the pass follows the physical path of the light:
*
* lens barrel distortion, lateral chromatic aberration
* shutter rolling shutter skew - a geometric read order, not a lens effect
* readout the exposure and white balance the automatics settled on
* amplifier noise, scaled by the gain those automatics had to apply
*/
export const opticsSensorShader = /*glsl*/ `
uniform float uTime; // seconds
// --- lens -----------------------------------------------------------
uniform float uBarrel; // k1: positive is barrel, the wide lens look
uniform float uChromAb; // lateral chromatic aberration, in pixels at the corner
// --- sensor ---------------------------------------------------------
uniform float uSkew; // rolling shutter, driven by how fast the camera turns
// --- automatics -----------------------------------------------------
uniform float uExposure; // multiplier the AE loop settled on this frame
uniform vec3 uWhiteBalance; // per channel gain from the AWB drift
uniform float uGain; // 0..1, how hard the AGC is pushing - drives the noise
uniform float uNoise; // base noise at zero gain
uniform float uFixedPattern; // share of the noise that does NOT change per frame
float hash13(vec3 p3) {
p3 = fract(p3 * 0.1031);
p3 += dot(p3, p3.zyx + 31.32);
return fract((p3.x + p3.y) * p3.z);
}
${lensRemapGlsl}
void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor) {
vec2 distorted = lensRemap(uv);
// Past the edge of the frame there is nothing to sample.
if (distorted.x < 0.0 || distorted.x > 1.0 || distorted.y < 0.0 || distorted.y > 1.0) {
outputColor = vec4(0.0, 0.0, 0.0, 1.0);
return;
}
// Lateral chromatic aberration: the refractive index varies with
// wavelength, so the channels land at slightly different scales. It
// grows with the radius and is exactly zero in the centre - a constant
// RGB offset across the whole frame is the giveaway of a fake.
vec3 colour;
if (uChromAb > 0.0) {
vec2 fromCentre = distorted - 0.5;
float spread = uChromAb / max(resolution.x, 1.0);
colour.r = texture2D(inputBuffer, distorted + fromCentre * spread).r;
colour.g = texture2D(inputBuffer, distorted).g;
colour.b = texture2D(inputBuffer, distorted - fromCentre * spread).b;
} else {
colour = texture2D(inputBuffer, distorted).rgb;
}
// What the automatics decided this frame.
colour *= uExposure;
colour *= uWhiteBalance;
// Noise rides on the gain, not on the clock. This coupling is what
// sells a dark shot: an image that brightens without getting noisier
// reads as a levels adjustment, because that is what it is.
float amount = uNoise * (1.0 + uGain * 6.0);
if (amount > 0.0) {
// Part of the pattern is burnt into the sensor and does not change
// between frames; the rest is shot noise and does.
vec2 pixel = uv * resolution;
float fixedNoise = hash13(vec3(pixel, 0.0)) - 0.5;
float shotNoise = hash13(vec3(pixel, floor(uTime * 60.0))) - 0.5;
float n = mix(shotNoise, fixedNoise, uFixedPattern);
// The blue channel is always the worst: fewer useful photons, more
// amplification to make up for it.
colour += vec3(n, n * 0.9, n * 1.6) * amount;
}
outputColor = vec4(max(colour, 0.0), inputColor.a);
}
`;
datamosh-fullproject/archive/camera-sim/shaders/signal.ts
/**
* Fragment shader of the signal stage.
*
* Second half of the camera chain, and it runs AFTER the datamosh: everything
* modelled here happens to a picture that has already been encoded, either on
* the way out of the codec or on the way to the screen.
*
* chroma the colour planes are carried at a fraction of the luma
* resolution, so colour smears sideways and arrives slightly
* after the contour it belongs to
* persistence the smear a slow sensor leaves behind a moving highlight -
* weighted by brightness, because that is where it is visible
* display vignette and grain
*
* The persistence is a feedback loop: `uTrail` is this shader's own output from
* the previous frame. It is an IIR filter, so it settles rather than diverging,
* and weighting it by luminance is what keeps it from turning the whole frame
* into mush - a real sensor holds onto bright things, not dark ones.
*/
export const signalShader = /*glsl*/ `
uniform sampler2D uTrail; // this effect's output, one frame ago
uniform float uTime; // seconds
uniform float uChromaBleed; // how far the colour smears horizontally
uniform float uChromaDelay; // colour lagging behind the contours, in pixels
uniform float uPersistence; // 0 = none, 1 = the image never lets go
uniform float uPersistenceBias; // how strongly the trail favours highlights
// --- grade ------------------------------------------------------------
uniform float uLift; // how far off the floor the blacks sit
uniform vec3 uLiftTint; // and which way they lean while they are there
uniform float uShoulder; // how far short of clipping the whites stop
uniform float uContrast;
uniform float uSaturation;
uniform vec3 uTint; // overall cast
// Lost-vector debug overlay. It lives here, downstream of the feedback
// copy, because the datamosh writes into the buffer the next frame predicts
// from: an outline drawn there is dragged and redrawn every frame until the
// screen is white.
uniform float uDebugLost;
uniform float uLostDensity;
uniform float uLostLayers;
uniform float uLostLife;
uniform float uLostScale;
uniform float uLostAspect;
uniform float uLostVariance;
uniform float uVignette;
uniform float uGrain;
float hash13(vec3 p3) {
p3 = fract(p3 * 0.1031);
p3 += dot(p3, p3.zyx + 31.32);
return fract((p3.x + p3.y) * p3.z);
}
vec2 hash23(vec3 p3) {
p3 = fract(p3 * vec3(0.1031, 0.1030, 0.0973));
p3 += dot(p3, p3.yzx + 33.33);
return fract((p3.xx + p3.yz) * p3.zy);
}
/** Same regions the datamosh computes, re-derived here for the outline. */
float lostEdge(const in vec2 uv) {
if (uLostDensity <= 0.0) return 0.0;
for (int i = 0; i < 4; i++) {
float fi = float(i);
if (fi >= uLostLayers) break;
vec2 cells = vec2(7.0, 5.0) * (1.0 + fi * 1.6) / max(uLostScale, 0.05);
vec2 cellId = floor(uv * cells);
vec2 inCell = fract(uv * cells);
float phase = hash13(vec3(cellId, fi + 5.0));
float t = uTime * 1000.0 / max(uLostLife, 16.0) + phase;
float slot = floor(t);
float age = fract(t);
if (hash13(vec3(cellId, slot * 7.0 + fi)) > uLostDensity) continue;
vec2 centre = 0.2 + hash23(vec3(cellId, slot + fi * 23.0)) * 0.6;
vec2 stretch = vec2(uLostAspect, 1.0 / max(uLostAspect, 0.05));
vec2 halfSize = (0.05 + hash23(vec3(cellId, slot + fi * 41.0))
* vec2(0.45, 0.26) * uLostVariance) * stretch;
vec2 d = abs(inCell - centre);
if (d.x > halfSize.x || d.y > halfSize.y) continue;
if (age > 0.12 + hash13(vec3(cellId, slot + 61.0)) * 0.45) continue;
float edge = min((halfSize.x - d.x) / cells.x * resolution.x,
(halfSize.y - d.y) / cells.y * resolution.y);
return step(edge, 1.5);
}
return 0.0;
}
float luma(vec3 c) {
return dot(c, vec3(0.299, 0.587, 0.114));
}
// BT.601: luma and chroma kept apart, the way every codec and every
// broadcast standard does it.
vec3 rgbToYcc(vec3 c) {
return vec3(
dot(c, vec3(0.299, 0.587, 0.114)),
dot(c, vec3(-0.168736, -0.331264, 0.5)),
dot(c, vec3(0.5, -0.418688, -0.081312))
);
}
vec3 yccToRgb(vec3 c) {
return vec3(
c.x + 1.402 * c.z,
c.x - 0.344136 * c.y - 0.714136 * c.z,
c.x + 1.772 * c.y
);
}
void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor) {
vec3 colour = inputColor.rgb;
// --- chroma: full bandwidth luma, a fraction of it for colour -------
if (uChromaBleed > 0.0) {
vec3 chroma = vec3(0.0);
float width = uChromaBleed * 18.0 / resolution.x;
for (int i = 0; i < 9; i++) {
float t = float(i) / 8.0 - 0.5;
vec2 tap = vec2(uv.x + t * width - uChromaDelay / resolution.x, uv.y);
chroma += rgbToYcc(texture2D(inputBuffer, clamp(tap, 0.001, 0.999)).rgb);
}
chroma /= 9.0;
vec3 ycc = rgbToYcc(colour);
colour = yccToRgb(vec3(ycc.x, chroma.y, chroma.z));
}
// --- grade -----------------------------------------------------------
// The washed look of real footage is not a stylistic choice, it is two
// physical facts stacked on top of each other.
//
// First, video is not full range: the standards put black at 16 and
// white at 235 out of 255, and cheap hardware routinely fails to expand
// it back on playback. The picture therefore arrives with its floor
// already lifted and its ceiling already lowered.
//
// Second, veiling glare - light scattered inside a small, uncoated,
// usually smudged lens - adds a roughly uniform sheet of light across
// the whole frame. It does nothing to the highlights and everything to
// the shadows, which is why the blacks of a body-worn camera are milky
// and slightly the colour of whatever is brightest in the room.
//
// Remapping the range reproduces both, and it has to happen before the
// saturation so the desaturation acts on the flattened image rather
// than on a contrast it no longer has.
colour = mix(uLift * uLiftTint, vec3(1.0 - uShoulder), colour);
colour = (colour - 0.5) * uContrast + 0.5;
float grey = luma(colour);
colour = mix(vec3(grey), colour, uSaturation);
colour *= uTint;
// --- persistence ----------------------------------------------------
if (uPersistence > 0.0) {
vec3 previous = texture2D(uTrail, uv).rgb;
// Only what was bright leaves a trail, and the trail is taken as a
// maximum rather than a blend: a highlight that has moved on should
// fade from where it was, not darken what is there now.
float weight = uPersistence
* mix(1.0, smoothstep(0.25, 0.85, luma(previous)), uPersistenceBias);
colour = max(colour, previous * weight);
}
// --- display ---------------------------------------------------------
if (uVignette > 0.0) {
vec2 centred = (uv - 0.5) * vec2(aspect, 1.0);
float r = length(centred) / 0.72;
colour *= 1.0 - uVignette * pow(clamp(r, 0.0, 1.0), 2.5);
}
if (uGrain > 0.0) {
float g = hash13(vec3(uv * resolution, floor(uTime * 60.0) + 17.0)) - 0.5;
colour += g * uGrain;
}
if (uDebugLost > 0.5) {
colour = mix(colour, vec3(1.0), lostEdge(uv));
}
outputColor = vec4(clamp(colour, 0.0, 1.0), inputColor.a);
}
`;
datamosh-fullproject/archive/camera-sim/signal.ts
import { BlendFunction, Effect } from "postprocessing";
import * as THREE from "three";
import { signalShader } from "./shaders/signal";
export interface SignalSettings {
/** How far colour smears sideways, from the chroma being carried at lower resolution. */
chromaBleed: number;
/** Colour arriving after the contour it belongs to, in pixels. */
chromaDelay: number;
/** Sensor persistence: the smear a moving highlight leaves behind it. */
persistence: number;
/** How strongly that trail favours highlights over the whole frame. */
persistenceBias: number;
/** How far off the floor the blacks sit: veiling glare plus limited range. */
lift: number;
/** Which way the lifted blacks lean. */
liftTint: string;
/** How far short of clipping the whites stop. */
shoulder: number;
contrast: number;
saturation: number;
/** Overall cast. */
tint: string;
vignette: number;
grain: number;
}
/** Shape of the lost-vector regions, mirrored here only to outline them. */
export interface LostDebugSettings {
enabled: boolean;
density: number;
layers: number;
life: number;
scale: number;
aspect: number;
variance: number;
}
/**
* Signal stage: what happens to the picture after the encoder, on its way to
* the screen.
*
* The persistence needs its own frame of history, which the manager supplies as
* a render target it copies into after this pass has run. That target is the
* shader's own previous output, so the trail compounds frame after frame and
* settles into an exponential tail rather than a hard cut.
*/
export class SignalEffect extends Effect {
private elapsed = 0;
private readonly colour = new THREE.Color();
constructor(settings: SignalSettings) {
super("Signal", signalShader, {
blendFunction: BlendFunction.SRC,
uniforms: new Map<string, THREE.Uniform>([
["uTrail", new THREE.Uniform(null)],
["uTime", new THREE.Uniform(0)],
["uChromaBleed", new THREE.Uniform(settings.chromaBleed)],
["uChromaDelay", new THREE.Uniform(settings.chromaDelay)],
["uPersistence", new THREE.Uniform(settings.persistence)],
["uPersistenceBias", new THREE.Uniform(settings.persistenceBias)],
["uLift", new THREE.Uniform(settings.lift)],
["uLiftTint", new THREE.Uniform(new THREE.Vector3(1, 1, 1))],
["uShoulder", new THREE.Uniform(settings.shoulder)],
["uContrast", new THREE.Uniform(settings.contrast)],
["uSaturation", new THREE.Uniform(settings.saturation)],
["uTint", new THREE.Uniform(new THREE.Vector3(1, 1, 1))],
["uDebugLost", new THREE.Uniform(0)],
["uLostDensity", new THREE.Uniform(0)],
["uLostLayers", new THREE.Uniform(3)],
["uLostLife", new THREE.Uniform(180)],
["uLostScale", new THREE.Uniform(1)],
["uLostAspect", new THREE.Uniform(1)],
["uLostVariance", new THREE.Uniform(1)],
["uVignette", new THREE.Uniform(settings.vignette)],
["uGrain", new THREE.Uniform(settings.grain)],
]),
});
}
setTrailTexture(texture: THREE.Texture): void {
this.uniforms.get("uTrail")!.value = texture;
}
update(
_renderer: THREE.WebGLRenderer,
_inputBuffer: THREE.WebGLRenderTarget,
deltaTime = 1 / 60,
): void {
this.elapsed += THREE.MathUtils.clamp(deltaTime, 1 / 240, 1 / 15);
this.uniforms.get("uTime")!.value = this.elapsed;
}
/** Reads a css colour into a uniform without allocating a new vector. */
private setColourUniform(name: string, css: string): void {
this.colour.set(css);
(this.uniforms.get(name)!.value as THREE.Vector3).set(
this.colour.r,
this.colour.g,
this.colour.b,
);
}
/** Debug overlay: the regions whose vectors the datamosh dropped. */
setLostDebug(lost: LostDebugSettings): void {
this.uniforms.get("uDebugLost")!.value = lost.enabled ? 1 : 0;
this.uniforms.get("uLostDensity")!.value = lost.density;
this.uniforms.get("uLostLayers")!.value = lost.layers;
this.uniforms.get("uLostLife")!.value = lost.life;
this.uniforms.get("uLostScale")!.value = lost.scale;
this.uniforms.get("uLostAspect")!.value = lost.aspect;
this.uniforms.get("uLostVariance")!.value = lost.variance;
}
applySettings(settings: SignalSettings): void {
this.uniforms.get("uChromaBleed")!.value = settings.chromaBleed;
this.uniforms.get("uChromaDelay")!.value = settings.chromaDelay;
this.uniforms.get("uPersistence")!.value = settings.persistence;
this.uniforms.get("uPersistenceBias")!.value = settings.persistenceBias;
this.uniforms.get("uLift")!.value = settings.lift;
this.uniforms.get("uShoulder")!.value = settings.shoulder;
this.uniforms.get("uContrast")!.value = settings.contrast;
this.uniforms.get("uSaturation")!.value = settings.saturation;
this.setColourUniform("uLiftTint", settings.liftTint);
this.setColourUniform("uTint", settings.tint);
this.uniforms.get("uVignette")!.value = settings.vignette;
this.uniforms.get("uGrain")!.value = settings.grain;
}
}
datamosh-fullproject/eslint.config.mjs
import js from '@eslint/js';
import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import tseslint from 'typescript-eslint';
export default tseslint.config(
// Build artefacts: Vite writes the static site into `dist`.
{ ignores: ['dist/**'] },
{
files: ['**/*.{ts,tsx}'],
extends: [js.configs.recommended, ...tseslint.configs.recommended],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
// The two hook rules `next/core-web-vitals` used to provide. The plugin's
// own `recommended` set now also turns on the React Compiler rules
// (purity, immutability, refs, set-state-in-effect), which a
// react-three-fiber scene breaks by design: `useFrame` mutates objects
// and refs every frame on purpose. Widening the gate is a separate call
// from moving off Next.
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'warn',
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
},
},
);
datamosh-fullproject/index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<!--
The scene is a full-screen WebGL canvas driven by pointer/scroll gestures,
so pinch-zoom is disabled on purpose to avoid fighting the camera controls.
-->
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"
/>
<meta name="theme-color" content="#000000" />
<meta name="color-scheme" content="dark" />
<title>Datamosh — real-time WebGL glitch corruption demo</title>
<meta
name="description"
content="Interactive WebGL datamoshing: hold to freeze frame refresh and smear a 3D room along camera motion, with motion vectors and macroblocks in GLSL shaders."
/>
<meta name="keywords" content="datamosh, glitch art, WebGL, three.js, shader effects" />
<meta name="author" content="Niccolò Fanton" />
<link rel="author" href="https://niccolofanton.dev" />
<link rel="canonical" href="https://datamosh.niccolofanton.dev/" />
<link rel="manifest" href="manifest.json" />
<link rel="icon" href="favicon.ico" sizes="32x32" />
<link rel="icon" href="icon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="apple-touch-icon.png" sizes="180x180" />
<meta property="og:type" content="website" />
<meta property="og:url" content="https://datamosh.niccolofanton.dev/" />
<meta property="og:site_name" content="Niccolò Fanton" />
<meta property="og:title" content="Datamosh — real-time WebGL glitch corruption demo" />
<meta
property="og:description"
content="Interactive WebGL datamoshing: hold to freeze frame refresh and smear a 3D room along camera motion, with motion vectors and macroblocks in GLSL shaders."
/>
<meta property="og:image" content="https://datamosh.niccolofanton.dev/og.png" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:alt" content="A green-lit 3D room smeared into blue and cyan datamosh artefacts" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Datamosh — real-time WebGL glitch corruption demo" />
<meta
name="twitter:description"
content="Interactive WebGL datamoshing: hold to freeze frame refresh and smear a 3D room along camera motion, with motion vectors and macroblocks in GLSL shaders."
/>
<meta name="twitter:image" content="https://datamosh.niccolofanton.dev/og.png" />
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "SoftwareApplication",
"name": "Datamosh",
"description": "Interactive WebGL datamoshing: hold to freeze frame refresh and smear a 3D room along camera motion, with motion vectors and macroblocks in GLSL shaders.",
"url": "https://datamosh.niccolofanton.dev/",
"image": "https://datamosh.niccolofanton.dev/og.png",
"applicationCategory": "MultimediaApplication",
"operatingSystem": "Any browser with WebGL 2",
"codeRepository": "https://github.com/niccolofanton/data-mosh",
"author": {
"@type": "Person",
"name": "Niccolò Fanton",
"url": "https://niccolofanton.dev",
"sameAs": ["https://github.com/niccolofanton"]
}
}
</script>
<!--
The scene's only heavy asset. three.js' FileLoader fetches it with the
default CORS mode, so `as="fetch"` + `crossorigin="anonymous"` is what
makes the preload match the real request instead of duplicating it.
-->
<link
rel="preload"
href="models/backroom-transformed.glb"
as="fetch"
type="model/gltf-binary"
crossorigin="anonymous"
/>
<!--
That GLB declares KHR_draco_mesh_compression, and drei points its
DRACOLoader at gstatic - so parsing the file that gates the whole canvas
cannot begin until a wasm wrapper and a decoder land from a third origin,
behind a fresh DNS lookup and TLS handshake. Warming the connection takes
that round trip off the critical path.
-->
<link rel="preconnect" href="https://www.gstatic.com" crossorigin="anonymous" />
<!-- The rest of the scene: not render-blocking, but wanted early. -->
<link rel="preload" href="hdri/kloppenheim-puresky-1k.hdr" as="fetch" crossorigin="anonymous" />
<!--
Every style the demo has. Inline rather than a stylesheet: it is under
forty lines, and as a module import it became a separate render-blocking
request in front of a page whose whole job is to start a WebGL context.
The page is one full-viewport canvas. Nothing scrolls, nothing else paints.
-->
<style>
/*
The frame, in the house style of the Singularity demo: a big bold
wordmark top left with the byline and the Codrops links under it, the
tags along the bottom, the settings link top right.
Helvetica throughout - display weight for the wordmark, regular for
everything else - and no webfont, so none of it is on the critical path
of a page whose whole job is to start a WebGL context.
*/
:root {
--frame-font: Helvetica, 'Helvetica Neue', Arial, sans-serif;
--frame-inset: 20px;
}
.frame {
position: fixed;
inset: 0;
z-index: 10;
padding: var(--frame-inset);
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: flex-start;
/* The canvas underneath takes the pointer; only the anchors take it back. */
pointer-events: none;
font-family: var(--frame-font);
font-size: 12px;
line-height: 18px;
color: #fff;
/*
The shot runs from a near-black corridor to a lit green floor, so a
flat colour is unreadable somewhere in every frame. The halo is what
Singularity uses, inverted for a dark scene: it gives the type its own
local contrast wherever it lands.
*/
filter: drop-shadow(0 0 10px rgb(0 0 0 / 90%));
}
.frame a {
pointer-events: auto;
color: inherit;
text-decoration: none;
}
.frame a:hover,
.frame a:focus-visible {
text-decoration: underline;
}
/* Wordmark. Bold and tight: the line box is the cap height, near enough. */
.frame__title {
margin: 0 0 -2px;
font-size: 45px;
line-height: 45px;
font-weight: 700;
letter-spacing: -0.02em;
color: #fff;
user-select: none;
}
.frame__byline {
margin: 0;
font-size: 10.5px;
line-height: 15px;
color: #fff;
}
.frame__byline a {
text-decoration: underline;
}
/*
The top-left block: wordmark, byline, links. It exists so the frame's
column has exactly two items - this and the tags - and `space-between`
pushes those to opposite ends instead of spreading three separate lines
down the whole viewport.
*/
.frame__head {
display: block;
}
.frame__links {
display: flex;
gap: 20px;
padding-top: 12px;
}
.frame__tags {
display: flex;
gap: 20px;
}
/* Its own corner, out of the flex column. */
.frame__settings {
position: fixed;
top: 16px;
right: var(--frame-inset);
font-size: 9px;
line-height: 12px;
text-decoration: underline;
color: #fff;
}
/*
The gesture is the whole demo and nothing on screen says so, which is
how a visitor ends up looking at a still render and leaving. It hides
itself the first time the pointer goes down.
*/
.frame__hint {
position: fixed;
left: 50%;
bottom: 5vh;
transform: translateX(-50%);
padding: 3px 9px;
border-radius: 9999px;
background: rgb(255 255 255 / 60%);
box-shadow: 0 0 0 1px rgb(24 24 27 / 5%), 0 10px 15px -3px rgb(0 0 0 / 20%);
font-family: var(--frame-font);
font-size: 10.5px;
line-height: 15px;
font-weight: 500;
color: #27272a;
user-select: none;
transition: opacity 0.4s ease;
}
body.touched .frame__hint {
opacity: 0;
}
#cdawrap {
position: fixed;
right: var(--frame-inset);
bottom: var(--frame-inset);
max-width: 300px;
text-align: right;
pointer-events: auto;
}
/* The wordmark alone would eat a phone screen at 45px. */
@media screen and (max-width: 40em) {
.frame__title {
font-size: 32px;
line-height: 32px;
}
.frame__links,
.frame__tags {
flex-wrap: wrap;
gap: 12px;
}
}
/*
Preloader. The scene is gated on 8 MB of room, rig and sky, and until
those land the canvas is a black rectangle that looks broken rather
than busy. The overlay is markup in this file, not in the bundle, so it
paints on the first frame - before React or three.js have been parsed -
and `body.loading` is dropped once three's loading manager reports the
last asset in (see src/preloader.tsx).
*/
.loader {
position: fixed;
inset: 0;
z-index: 2000;
display: grid;
place-content: center;
justify-items: center;
gap: 14px;
background: #000;
font-family: var(--frame-font);
font-size: 10.5px;
line-height: 15px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #fff;
transition: opacity 0.6s ease;
}
/* The bar tracks real progress, so it is a scale rather than an animation. */
.loader__bar {
width: 160px;
height: 2px;
background: rgb(255 255 255 / 15%);
}
.loader__fill {
display: block;
height: 100%;
background: #fff;
transform: scaleX(0);
transform-origin: left;
transition: transform 0.3s ease;
}
/*
`visibility` rather than `display`, so the fade has something to run on,
and `pointer-events` so the canvas is reachable the instant it is gone.
*/
body:not(.loading) .loader {
opacity: 0;
visibility: hidden;
pointer-events: none;
}
/* r3f sizes its canvas to its container, so the mount point needs the height too. */
html,
body,
#root {
height: 100%;
margin: 0;
overflow: hidden;
background: #000;
}
/* Block, so the canvas does not sit on a text baseline and leave a gap under it. */
canvas {
display: block;
user-select: none;
-webkit-user-select: none;
}
/*
Tweakpane's panel is 256px wide and unbounded in height, and this one
carries about fifty controls: with two folders open it runs off the
bottom of a laptop screen with no way to reach the rest. At 256px the
longer labels ("Keyframe Every (ms)") are truncated as well.
The class is doubled to win on specificity without `!important`:
Tweakpane injects its own stylesheet when the Pane is constructed, i.e.
after this one, so at equal specificity its 256px would come last.
*/
.tp-dfwv.tp-dfwv {
width: 300px;
max-height: calc(100dvh - 16px);
overflow-y: auto;
overscroll-behavior: contain;
}
/* The value column the controls were tuned against. */
.tp-dfwv .tp-rotv {
--tp-blade-value-width: 140px;
--tp-container-unit-spacing: 6px;
}
/*
Crawler-visible copy. The page is one canvas, so the only text a bot can
read is what sits in this file; this block carries it without painting.
*/
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0 0 0 0);
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
</style>
</head>
<body class="loading">
<div class="loader" role="status" aria-live="polite">
<span>Loading</span>
<span class="loader__bar"><span class="loader__fill" id="loader-fill"></span></span>
</div>
<div id="root"></div>
<header class="frame">
<div class="frame__head">
<h1 class="frame__title">DATAMOSH</h1>
<p class="frame__byline">by <a href="https://x.com/niccolofanton" target="_blank" rel="noopener">@niccolofanton</a></p>
<nav class="frame__links">
<!-- Codrops fills the post id in once the article has one. -->
<a href="https://tympanus.net/codrops/?p=">Read the tutorial</a>
<a href="https://github.com/niccolofanton/data-mosh" target="_blank" rel="noopener">GitHub</a>
<a href="https://tympanus.net/codrops/demos/">All demos</a>
</nav>
</div>
<nav class="frame__tags">
<a href="https://tympanus.net/codrops/demos/?tag=three-js">#three.js</a>
<a href="https://tympanus.net/codrops/demos/?tag=webgl">#webgl</a>
<a href="https://tympanus.net/codrops/demos/?tag=shader">#shader</a>
<a href="https://tympanus.net/codrops/demos/?tag=glitch">#glitch</a>
</nav>
<a class="frame__settings" href="?debug">Show settings</a>
<p class="frame__hint">PRESS & HOLD</p>
</header>
<section class="sr-only">
<h2>Real-time datamoshing in WebGL</h2>
<p>
A walkable 3D room rendered with three.js and React Three Fiber. Holding the pointer down
stops the renderer from refreshing the frame and feeds the previous colour buffer back into
the next one, displaced by the scene's motion vectors: the same trick that produces
datamosh artefacts in compressed video, done live on the GPU. Camera motion smears the
macroblocks, geometry edges leak residuals, and releasing the press lets the image resolve.
The GLSL passes are in
<a href="https://github.com/niccolofanton/data-mosh">the data-mosh repository on GitHub</a>.
</p>
<p>
Made by <a href="https://niccolofanton.dev">Niccolò Fanton</a>.
Browse <a href="https://demos.niccolofanton.dev">all WebGL demos on the demo hub</a>.
</p>
</section>
<script>
// Not in the bundle: the hint has to answer the very first press, which
// can land before the module graph has finished parsing.
addEventListener(
'pointerdown',
() => document.body.classList.add('touched'),
{ once: true },
);
</script>
<script type="module" src="/src/main.tsx"></script>
<!--script src="https://tympanus.net/codrops/adpacks/cda_sponsor.js"></script-->
</body>
</html>
datamosh-fullproject/src/controls.ts
import { useSyncExternalStore } from 'react';
import { createDriftpane } from '@niccolofanton/driftpane';
import { Pane } from 'tweakpane';
/**
* The demo's control panel: one Tweakpane, built once at boot.
*
* Everything the panel can change lives in the single mutable object below, and
* Tweakpane binds straight into it - no schema layer, no registry, no React
* ownership. That is the whole design, and it is possible because of what the
* controls actually drive: almost all of them end up in
* `DataMoshManager.updateSettings`, which is imperative and runs inside the
* render loop. Routing them through component state only ever added a commit
* between a slider and the frame it was meant to change.
*
* The two that genuinely need React - one mounts the perf overlay, one is a
* prop - read through `useControl`, which subscribes to the same notification.
*
* Defaults are hand-tuned against what the effect looks like on screen, so they
* are kept together here rather than scattered across the call sites.
*/
export const controls = {
// --- 🎞️ Datamosh ---------------------------------------------------------
effectEnabled: true,
/**
* Holds the trigger down without anything being held down. The effect is a
* gesture - press, mosh, release, recover - and there is otherwise no way to
* sit inside one long enough to look at it, move a slider, or read the debug
* buffers while it is running.
*/
latch: false,
sceneCut: true,
motionSource: 'velocity' as 'velocity' | 'camera',
motionGain: 1.5,
parallax: 1,
fadeDuration: 370,
/**
* In milliseconds rather than in frames: the pumping this produces is a
* rhythm, and a rhythm should not speed up on a 120 Hz screen. It also keeps
* the unit consistent with the other two time controls of the panel.
*/
keyframeInterval: 0,
// --- 🧱 Macroblocks -------------------------------------------------------
blockSize: 8,
/**
* A plain off switch for the block grid. Turning it off is not "less
* datamosh": it is the continuous per-pixel warp, which is what an optical
* flow based emulation looks like - liquid rather than tiled.
*/
macroblocks: true,
blockiness: 1,
/**
* Codecs do not store motion vectors at pixel resolution. Half pel is what
* MPEG-4 Part 2 uses, and MPEG-4 Part 2 in AVI is the container/codec pair
* the classic look comes from.
*/
mvPrecision: 2,
/**
* Zero by default: above it, blocks below the threshold are not coded at all
* and freeze for good, which reads as untouched holes punched in the moshed
* picture rather than as ghosting.
*/
skipThreshold: 0,
/** How likely any one cell is to lose its vector. */
frozenBlocks: 1,
/**
* How many independent grids of regions are laid over each other, each one
* finer than the last. Density says how likely a cell is to drop out; this
* says how many populations of cells there are to drop out at all, which is
* the other half of "how much of the frame is lost" and the one that changes
* the size mix rather than just the count.
*/
lostLayers: 4,
lostLife: 50,
lostScale: 2.65,
lostAspect: 0.75,
lostVariance: 1,
mismatch: 0,
// --- 🩸 Residual ----------------------------------------------------------
/**
* Carried at ten times its real value and divided at the mapping. The useful
* range sits almost entirely under 0.05, where a slider stepping in
* hundredths has three or four positions in total; scaling the control lets
* it resolve down to 0.001 without giving the shader a different meaning.
*/
residualGain: 4,
residualQuant: 14,
// --- 🔬 Pipeline ----------------------------------------------------------
resolutionScale: 1,
antialias: true,
debugFrames: false,
debugMotion: false,
debugScale: 8,
// --- ⚡ Diagnostics -------------------------------------------------------
showPerf: false,
autoRotate: false,
};
export type Controls = typeof controls;
// --- Notification ------------------------------------------------------------
const listeners = new Set<() => void>();
/** Fires after any control changes. The values are read off `controls`. */
export const subscribeControls = (listener: () => void): (() => void) => {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
};
/** Notifies every subscriber. Copied first, so unsubscribing mid-loop is safe. */
const notify = (): void => {
for (const listener of [...listeners]) listener();
};
/**
* A single control as React state, for the two that have to be: `showPerf`
* mounts a component, `autoRotate` is a prop. Everything else is read
* imperatively and never causes a render.
*
* The selector must return a primitive - `useSyncExternalStore` compares
* snapshots by identity, and `controls` is mutated in place rather than
* replaced, so returning the object itself would never register as a change.
*/
export const useControl = <T extends number | boolean | string>(
select: (values: Controls) => T,
): T => useSyncExternalStore(subscribeControls, () => select(controls));
// --- The pane ----------------------------------------------------------------
/**
* Builds the panel. Called once from `main.tsx`, before React mounts.
*
* Folder order is the order these calls are written in, and the diagnostics
* start collapsed: the effect's own controls are what the demo is about.
*/
export const createControlPane = (): (() => void) => {
const pane = new Pane({ title: 'Data Mosh' });
const mosh = pane.addFolder({ title: '🎞️ Datamosh' });
mosh.addBinding(controls, 'effectEnabled', { label: 'Enabled' });
mosh.addBinding(controls, 'latch', { label: 'Hold Trigger' });
mosh.addBinding(controls, 'sceneCut', { label: 'Cut On Trigger' });
mosh.addBinding(controls, 'motionSource', {
label: 'Motion Source',
options: { 'Velocity Buffer': 'velocity', 'Camera Only': 'camera' },
});
mosh.addBinding(controls, 'motionGain', { label: 'Motion Gain', min: 0, max: 30, step: 0.5 });
mosh.addBinding(controls, 'parallax', { label: 'Parallax (Background)', min: 0, max: 4, step: 0.05 });
mosh.addBinding(controls, 'fadeDuration', { label: 'Recovery (ms)', min: 1, max: 2000, step: 1 });
mosh.addBinding(controls, 'keyframeInterval', { label: 'Keyframe Every (ms)', min: 0, max: 3000, step: 10 });
const blocks = pane.addFolder({ title: '🧱 Macroblocks' });
blocks.addBinding(controls, 'blockSize', { label: 'Macroblock (px)', min: 4, max: 64, step: 1 });
blocks.addBinding(controls, 'macroblocks', { label: 'Macroblocks' });
blocks.addBinding(controls, 'blockiness', { label: 'Block Quantise', min: 0, max: 1, step: 0.01 });
blocks.addBinding(controls, 'mvPrecision', {
label: 'Vector Precision',
options: { 'Full pel': 1, 'Half pel (MPEG-4)': 2, 'Quarter pel (H.264)': 4 },
});
blocks.addBinding(controls, 'skipThreshold', { label: 'Skip Below (px)', min: 0, max: 8, step: 0.05 });
blocks.addBinding(controls, 'frozenBlocks', { label: 'Lost: Density', min: 0, max: 1, step: 0.01 });
blocks.addBinding(controls, 'lostLayers', { label: 'Lost: Layers', min: 1, max: 4, step: 1 });
// Eight seconds at the top end: long enough that a region can outlive a whole
// gesture, which is what a vector lost for good looks like rather than one
// that blinks.
blocks.addBinding(controls, 'lostLife', { label: 'Lost: Refresh (ms)', min: 40, max: 8000, step: 5 });
blocks.addBinding(controls, 'lostScale', { label: 'Lost: Size', min: 0.2, max: 8, step: 0.05 });
blocks.addBinding(controls, 'lostAspect', { label: 'Lost: Aspect', min: 0.2, max: 5, step: 0.05 });
blocks.addBinding(controls, 'lostVariance', { label: 'Lost: Variance', min: 0, max: 1, step: 0.05 });
blocks.addBinding(controls, 'mismatch', { label: 'MC Mismatch', min: 0, max: 0.5, step: 0.01 });
const residual = pane.addFolder({ title: '🩸 Residual' });
residual.addBinding(controls, 'residualGain', { label: 'Residual Gain (x10)', min: 0, max: 30, step: 0.01 });
residual.addBinding(controls, 'residualQuant', { label: 'Quantiser Steps', min: 2, max: 64, step: 1 });
const pipeline = pane.addFolder({ title: '🔬 Pipeline', expanded: false });
pipeline.addBinding(controls, 'resolutionScale', { label: 'History/velocity texture scale', min: 0.2, max: 1, step: 0.05 });
pipeline.addBinding(controls, 'antialias', { label: 'Antialias (FXAA)' });
pipeline.addBinding(controls, 'debugFrames', { label: 'Show Frame Buffers' });
pipeline.addBinding(controls, 'debugMotion', { label: 'Show Motion Vectors' });
pipeline.addBinding(controls, 'debugScale', { label: 'Vector Scale (px)', min: 1, max: 40, step: 0.5 });
const diagnostics = pane.addFolder({ title: '⚡ Diagnostics', expanded: false });
diagnostics.addBinding(controls, 'showPerf', { label: 'Show Performance' });
diagnostics.addBinding(controls, 'autoRotate', { label: 'Auto Rotate Camera' });
// One listener on the root: Tweakpane bubbles every binding's change up to
// the pane, and it has already written the new value into `controls` by the
// time this runs.
pane.on('change', notify);
// Persistence, dragging and presets, added once the pane is fully built.
// A restore goes through Tweakpane's `importState()`, which re-fires the
// binding `change` handlers for every value that actually differs; that
// bubbles to the root listener above, so `notify` runs on its own and
// Driftpane refreshes the widgets. No re-apply pass is needed here.
createDriftpane(pane, { storageNamespace: 'datamosh-fullproject', width: 300 });
return () => pane.dispose();
};
// When this module is replaced, the pane it built is orphaned on screen.
import.meta.hot?.dispose(() => {
document.querySelector('.tp-dfwv')?.remove();
});
datamosh-fullproject/src/datamosh/debug-view.ts
import * as THREE from "three";
/**
* Small on screen previews of the intermediate frame buffers of the datamosh
* pipeline (keyframe, feedback, depth, velocity).
*
* Reading a texture back to a 2D canvas means a synchronous GPU -> CPU
* transfer, which is expensive, so the views share a single quad and each one
* keeps its render target and pixel buffer alive across calls and refreshes at
* a handful of frames per second instead of at render rate.
*/
const VIEW_WIDTH = 192;
const DEFAULT_INTERVAL = 1000 / 8;
export type DebugViewMode = "color" | "depth" | "velocity";
const MODE_INDEX: Record<DebugViewMode, number> = {
color: 0,
depth: 1,
velocity: 2,
};
/**
* Amplification of the motion vectors in the velocity preview. They are a few
* thousandths of a uv unit, so the raw buffer reads as flat grey.
*/
const VELOCITY_PREVIEW_GAIN = 40;
const debugVertexShader = /*glsl*/ `
varying vec2 vUv;
void main() {
// The pixel read back from the render target starts at the bottom row while
// ImageData starts at the top one, so the source is sampled flipped.
vUv = vec2(uv.x, 1.0 - uv.y);
gl_Position = vec4(position.xy, 0.0, 1.0);
}
`;
const debugFragmentShader = /*glsl*/ `
uniform sampler2D uTexture;
uniform float uMode; // 0 = colour, 1 = depth, 2 = velocity
uniform float uNear;
uniform float uFar;
uniform float uVelocityGain;
varying vec2 vUv;
void main() {
vec4 texel = texture2D(uTexture, vUv);
if (uMode > 1.5) {
// Red = motion to the right, green = motion up, black = no coverage.
vec2 encoded = texel.rg * uVelocityGain * 0.5 + 0.5;
gl_FragColor = vec4(clamp(vec3(encoded, 0.5), 0.0, 1.0) * texel.a, 1.0);
return;
}
if (uMode > 0.5) {
// Hyperbolic depth -> view z -> normalised distance.
float viewZ = (uNear * uFar) / ((uFar - uNear) * texel.r - uFar);
float linear = clamp((-viewZ - uNear) / (uFar - uNear), 0.0, 1.0);
// Most of the scene sits in the first few percent of the range, so the
// near end is expanded to make the preview readable.
gl_FragColor = vec4(vec3(1.0 - pow(linear, 0.35)), 1.0);
return;
}
gl_FragColor = vec4(texel.rgb, 1.0);
}
`;
interface SharedResources {
scene: THREE.Scene;
camera: THREE.OrthographicCamera;
quad: THREE.Mesh;
users: number;
}
let shared: SharedResources | null = null;
const acquireShared = (): SharedResources => {
if (!shared) {
const scene = new THREE.Scene();
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
const quad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2));
quad.frustumCulled = false;
scene.add(quad);
shared = { scene, camera, quad, users: 0 };
}
shared.users++;
return shared;
};
const releaseShared = () => {
if (!shared) return;
shared.users--;
if (shared.users > 0) return;
shared.quad.geometry.dispose();
(shared.quad.material as THREE.Material).dispose();
shared = null;
};
/** One preview canvas, pinned to the left edge of the window. */
export class DebugFrameView {
private readonly canvas: HTMLCanvasElement;
private readonly context: CanvasRenderingContext2D | null;
private readonly renderTarget: THREE.WebGLRenderTarget;
private readonly material: THREE.ShaderMaterial;
private readonly imageData: ImageData;
private readonly pixels: Uint8Array;
private readonly resources: SharedResources;
private lastUpdate = 0;
constructor(
private readonly label: string,
slot: number,
aspect: number,
private readonly mode: DebugViewMode = "color",
private readonly interval: number = DEFAULT_INTERVAL,
) {
const width = VIEW_WIDTH;
const height = Math.max(1, Math.round(VIEW_WIDTH / Math.max(aspect, 0.1)));
this.canvas = document.createElement("canvas");
this.canvas.width = width;
this.canvas.height = height;
this.canvas.title = label;
Object.assign(this.canvas.style, {
position: "fixed",
left: "10px",
top: `${10 + slot * (height + 8)}px`,
zIndex: "1000",
pointerEvents: "none",
border: "1px solid rgba(255,255,255,0.4)",
} satisfies Partial<CSSStyleDeclaration>);
document.body.appendChild(this.canvas);
this.context = this.canvas.getContext("2d");
this.renderTarget = new THREE.WebGLRenderTarget(width, height, {
minFilter: THREE.LinearFilter,
magFilter: THREE.LinearFilter,
depthBuffer: false,
stencilBuffer: false,
});
// Keep the byte values in the same space as the source so the readback
// does not need any conversion.
this.renderTarget.texture.colorSpace = THREE.SRGBColorSpace;
this.material = new THREE.ShaderMaterial({
vertexShader: debugVertexShader,
fragmentShader: debugFragmentShader,
depthTest: false,
depthWrite: false,
uniforms: {
uTexture: { value: null },
uMode: { value: MODE_INDEX[mode] },
uNear: { value: 0.1 },
uFar: { value: 100 },
uVelocityGain: { value: VELOCITY_PREVIEW_GAIN },
},
});
// The readback writes straight into the ImageData backing store, so there
// is no per frame allocation and no copy before putImageData.
this.imageData = new ImageData(width, height);
this.pixels = new Uint8Array(this.imageData.data.buffer);
this.resources = acquireShared();
}
update(
gl: THREE.WebGLRenderer,
texture: THREE.Texture | null,
now: number,
camera?: THREE.PerspectiveCamera,
): void {
if (!texture || !this.context) return;
if (now - this.lastUpdate < this.interval) return;
this.lastUpdate = now;
if (this.mode === "depth" && camera) {
this.material.uniforms.uNear.value = camera.near;
this.material.uniforms.uFar.value = camera.far;
}
this.material.uniforms.uTexture.value = texture;
const previousTarget = gl.getRenderTarget();
const previousMaterial = this.resources.quad.material;
this.resources.quad.material = this.material;
gl.setRenderTarget(this.renderTarget);
gl.render(this.resources.scene, this.resources.camera);
gl.readRenderTargetPixels(
this.renderTarget,
0,
0,
this.canvas.width,
this.canvas.height,
this.pixels,
);
gl.setRenderTarget(previousTarget);
this.resources.quad.material = previousMaterial;
this.context.putImageData(this.imageData, 0, 0);
this.context.font = "10px ui-monospace, monospace";
this.context.fillStyle = "white";
this.context.fillText(this.label, 5, 12);
}
dispose(): void {
this.canvas.remove();
this.renderTarget.dispose();
this.material.dispose();
releaseShared();
}
}
datamosh-fullproject/src/datamosh/effect.ts
import { BlendFunction, Effect, EffectAttribute } from "postprocessing";
import * as THREE from "three";
import { mainImageShader } from "./shaders";
/**
* Everything the effect and the pipeline can be tuned with.
*
* The whole object is replaced on every update instead of being merged field by
* field: it is cheap, and it keeps a single source of truth for the settings.
*/
/** Where the motion vectors come from. */
export type MotionSource =
/** Screen space velocity buffer: camera *and* object movement. */
| "velocity"
/** Camera movement only, by exact depth reprojection. One render pass cheaper. */
| "camera";
export interface DataMoshSettings {
/** Master switch: when false the mosh and feedback passes are skipped. */
effectEnabled: boolean;
/**
* Cut the scene when a melt gesture starts, so the incoming motion belongs to
* a shot the picture on screen never saw: the camera jumps to another side of
* the room, the subject becomes another shape, the room changes scale. What
* exactly changes is up to whatever the scene subscribed to `sceneCut`.
*/
sceneCut: boolean;
/** Duration of the cross fade back to the clean image, in milliseconds. */
fadeDuration: number;
/**
* Milliseconds between two keyframes during a gesture. 0 keeps the very first
* keyframe for the whole gesture (an infinite GOP), which is the classic
* "all I-frames removed" look.
*/
keyframeInterval: number;
/** Which motion vector field drives the reprojection. */
motionSource: MotionSource;
/** Exaggeration of the motion vectors. 1 = physically correct. */
motionGain: number;
/**
* Weight of the depth dependent (translation) part of the motion. Only
* affects the camera-only source: a velocity buffer measures parallax
* directly, so there is nothing left to weight.
*/
parallax: number;
/** Macroblock edge, in pixels. */
blockSize: number;
/** 0 = continuous per pixel warp (liquid smear), 1 = fully quantised blocks. */
blockiness: number;
/** Share of coded macroblocks that lose their motion vector and stay put. */
frozenBlocks: number;
/** Overlapping grids of lost regions, each finer than the last. 1 to 4. */
lostLayers: number;
/** FXAA on the clean render, before the encoder sees it. */
antialias: boolean;
/** How long a lost-vector region lasts, in milliseconds. */
lostLife: number;
/** Region size: bigger value means fewer, larger regions. */
lostScale: number;
/** Above 1 the regions are wide rectangles, below 1 tall ones. */
lostAspect: number;
/** 0 = every region the same size, 1 = wildly varied. */
lostVariance: number;
/** Sub pixel steps per pixel: 1 = full pel, 2 = half pel (MPEG-4), 4 = quarter pel. */
mvPrecision: number;
/** Motion, in pixels, below which a block is not coded at all (skipped). */
skipThreshold: number;
/** Share of blocks that pick up a neighbour's vector (motion compensation mismatch). */
mismatch: number;
/** How much of the incoming picture bleeds through as residual. */
residualGain: number;
/** Residual quantisation steps; low = coarse, only strong edges survive. */
residualQuant: number;
/** Draw the motion field instead of the picture. */
debugMotion: boolean;
/** Pixels per frame that map to full brightness in the debug view. */
debugScale: number;
/** Scale of the frame buffers used by the feedback chain (0.1 - 1). */
resolutionScale: number;
/** Show the iFrame / pFrame / depth debug canvases. */
debugFrames: boolean;
}
export interface DataMoshOptions extends DataMoshSettings {
camera: THREE.Camera;
}
/**
* Datamosh effect: reprojects its own previous output with macroblock quantised
* motion vectors, and adds the residual of the incoming picture on top.
*
* Two motion vector sources are available. The default one is the screen space
* velocity buffer produced by `VelocityPass`, which measures every movement in
* the scene, camera and objects alike. The other is the camera reprojection
* computed in the shader from the depth buffer and the three matrices this
* class maintains: it only knows about the camera, but it costs no extra render
* pass, so it stays as a fallback. The two are alternatives, never summed - the
* velocity buffer already contains the camera contribution.
*
* The reprojection path is also used wherever the velocity buffer has no
* coverage, i.e. on background pixels no geometry was rasterised on.
*/
export class DataMoshEffect extends Effect {
private camera: THREE.Camera;
// Camera state of the previous frame. The full world matrix is kept rather
// than a position/quaternion pair because the shader needs the whole
// transform back, and re-composing it would only add rounding.
private readonly previousMatrixWorld = new THREE.Matrix4();
private readonly previousProjection = new THREE.Matrix4();
private hasPreviousCamera = false;
// Scratch objects, reused to avoid per frame allocations.
private readonly scratchMatrix = new THREE.Matrix4();
private readonly scratchPosition = new THREE.Vector3();
/** Depth texture handed over by the EffectPass, kept for the debug view. */
public depthTexture: THREE.Texture | null = null;
constructor({ camera, ...settings }: DataMoshOptions) {
const uniforms = new Map<string, THREE.Uniform>([
["pFrame", new THREE.Uniform(null)],
["uVelocity", new THREE.Uniform(null)],
["uTime", new THREE.Uniform(0)],
["uRecover", new THREE.Uniform(1)],
["uKeyframe", new THREE.Uniform(0)],
["uHistoryResolution", new THREE.Uniform(new THREE.Vector2(1, 1))],
["uInvViewProjection", new THREE.Uniform(new THREE.Matrix4())],
["uPrevViewProjection", new THREE.Uniform(new THREE.Matrix4())],
["uPrevViewProjectionRot", new THREE.Uniform(new THREE.Matrix4())],
["uMotionSource", new THREE.Uniform(0)],
["uMotionGain", new THREE.Uniform(settings.motionGain)],
["uParallax", new THREE.Uniform(settings.parallax)],
["uBlockSize", new THREE.Uniform(settings.blockSize)],
["uBlockiness", new THREE.Uniform(settings.blockiness)],
["uFrozenBlocks", new THREE.Uniform(settings.frozenBlocks)],
["uLostLayers", new THREE.Uniform(settings.lostLayers)],
["uLostLife", new THREE.Uniform(settings.lostLife)],
["uLostScale", new THREE.Uniform(settings.lostScale)],
["uLostAspect", new THREE.Uniform(settings.lostAspect)],
["uLostVariance", new THREE.Uniform(settings.lostVariance)],
["uMvPrecision", new THREE.Uniform(settings.mvPrecision)],
["uSkipThreshold", new THREE.Uniform(settings.skipThreshold)],
["uMismatch", new THREE.Uniform(settings.mismatch)],
["uResidualGain", new THREE.Uniform(settings.residualGain)],
["uResidualQuant", new THREE.Uniform(settings.residualQuant)],
["uDebugMotion", new THREE.Uniform(0)],
["uDebugScale", new THREE.Uniform(settings.debugScale)],
]);
super("DataMosh", mainImageShader, {
uniforms,
// The effect replaces the image entirely; it also needs the scene depth,
// which is what makes the EffectPass ask the composer for a depth texture
// (and gives the shader readDepth / getViewZ / cameraNear / cameraFar).
blendFunction: BlendFunction.SRC,
attributes: EffectAttribute.DEPTH,
});
this.camera = camera;
}
setPFrameTexture(texture: THREE.Texture): void {
this.uniforms.get("pFrame")!.value = texture;
}
setVelocityTexture(texture: THREE.Texture): void {
this.uniforms.get("uVelocity")!.value = texture;
}
/**
* The history and velocity textures can be smaller than the frame the pass
* decodes. The Catmull-Rom taps and the residual's low-pass reads position
* themselves in texels of *those* textures, so they need the real size, not
* the pass resolution.
*/
setHistoryResolution(width: number, height: number): void {
(this.uniforms.get("uHistoryResolution")!.value as THREE.Vector2).set(width, height);
}
/**
* Marks the current frame as a keyframe (I-frame): the decoder throws away
* its prediction and shows the picture it was just handed, which is what
* makes a finite GOP pump instead of drifting forever.
*/
setKeyframeRefresh(active: boolean): void {
this.uniforms.get("uKeyframe")!.value = active ? 1 : 0;
}
/**
* Recovery factor: 0 = fully moshed, 1 = clean image, in between = the tail
* of a gesture fading out.
*
* It is computed on the CPU, once per frame, by the manager. It used to be
* derived inside the shader from `performance.now()`, the release timestamp
* and the fade duration, all pushed through React state. That put the one
* thing that has to be reliable - the picture coming back - behind three
* things that are not guaranteed to be in step with the render loop: a React
* commit, a uniform upload and 32 bit float arithmetic on an unbounded clock.
*/
setRecovery(recover: number): void {
this.uniforms.get("uRecover")!.value = THREE.MathUtils.clamp(recover, 0, 1);
}
/**
* Density of the lost-vector regions, driven per frame by the manager rather
* than by the settings: it is held at 0 for the first stretch of a gesture,
* and the panel knows nothing about gestures.
*/
setLostDensity(density: number): void {
this.uniforms.get("uFrozenBlocks")!.value = density;
}
/** Called by the EffectPass with the composer's depth texture. */
setDepthTexture(
depthTexture: THREE.Texture,
depthPacking?: THREE.DepthPackingStrategies,
): void {
super.setDepthTexture(depthTexture, depthPacking);
this.depthTexture = depthTexture;
}
/**
* Records the camera transform of this frame so the next one can reproject
* against it.
*
* Must run on *every* frame, exactly like its counterpart on the velocity
* pass: skipping it while the effect is idle would make the first frame of a
* gesture measure against a transform from an arbitrarily long time ago and
* produce one huge, wrong vector. Calling it a second time right after a cut
* is also how the jump itself is kept out of the motion field.
*/
capturePreviousState(): void {
this.previousMatrixWorld.copy(this.camera.matrixWorld);
this.previousProjection.copy(this.camera.projectionMatrix);
this.hasPreviousCamera = true;
}
update(): void {
this.uniforms.get("uTime")!.value = performance.now();
this.updateReprojectionMatrices();
}
/**
* Feeds the shader the three matrices it needs to answer "where was this
* point one frame ago".
*
* The previous transform is handed over twice: complete, and with its
* translation replaced by the current camera position. Reprojecting a point
* with both and taking the difference isolates the parallax exactly, which is
* what makes `parallax` a physically meaningful weight rather than a fudge
* factor - and it needs no small angle approximation, so roll and combined
* movements come out right.
*/
private updateReprojectionMatrices(): void {
const camera = this.camera;
// (P * V)^-1 = V^-1 * P^-1, and V^-1 is the camera's world matrix.
(this.uniforms.get("uInvViewProjection")!.value as THREE.Matrix4)
.copy(camera.matrixWorld)
.multiply(camera.projectionMatrixInverse);
const previousViewProjection = this.uniforms.get("uPrevViewProjection")!
.value as THREE.Matrix4;
const previousViewProjectionRot = this.uniforms.get(
"uPrevViewProjectionRot",
)!.value as THREE.Matrix4;
// Without a previous frame to compare against (first frame, or the frame
// right after a cut) the previous transform is the current one, which
// yields exactly zero motion instead of a delta against stale data.
const matrixWorld = this.hasPreviousCamera
? this.previousMatrixWorld
: camera.matrixWorld;
const projection = this.hasPreviousCamera
? this.previousProjection
: camera.projectionMatrix;
previousViewProjection.multiplyMatrices(
projection,
this.scratchMatrix.copy(matrixWorld).invert(),
);
camera.getWorldPosition(this.scratchPosition);
this.scratchMatrix.copy(matrixWorld).setPosition(this.scratchPosition);
previousViewProjectionRot.multiplyMatrices(
projection,
this.scratchMatrix.invert(),
);
}
/** Pushes the settings into the uniforms. */
applySettings(settings: DataMoshSettings): void {
this.uniforms.get("uMotionSource")!.value =
settings.motionSource === "velocity" ? 1 : 0;
this.uniforms.get("uMotionGain")!.value = settings.motionGain;
this.uniforms.get("uParallax")!.value = settings.parallax;
this.uniforms.get("uBlockSize")!.value = settings.blockSize;
this.uniforms.get("uBlockiness")!.value = settings.blockiness;
this.uniforms.get("uFrozenBlocks")!.value = settings.frozenBlocks;
this.uniforms.get("uLostLayers")!.value = settings.lostLayers;
this.uniforms.get("uLostLife")!.value = settings.lostLife;
this.uniforms.get("uLostScale")!.value = settings.lostScale;
this.uniforms.get("uLostAspect")!.value = settings.lostAspect;
this.uniforms.get("uLostVariance")!.value = settings.lostVariance;
this.uniforms.get("uMvPrecision")!.value = settings.mvPrecision;
this.uniforms.get("uSkipThreshold")!.value = settings.skipThreshold;
this.uniforms.get("uMismatch")!.value = settings.mismatch;
this.uniforms.get("uResidualGain")!.value = settings.residualGain;
this.uniforms.get("uResidualQuant")!.value = settings.residualQuant;
this.uniforms.get("uDebugMotion")!.value = settings.debugMotion ? 1 : 0;
this.uniforms.get("uDebugScale")!.value = settings.debugScale;
}
}
datamosh-fullproject/src/datamosh/manager.ts
import {
CopyPass,
EffectComposer,
EffectPass,
FXAAEffect,
Pass,
RenderPass,
} from "postprocessing";
import * as THREE from "three";
import { DataMoshEffect, DataMoshSettings } from "./effect";
import { supportsVelocityBuffer, VelocityPass } from "./velocity-pass";
import { moshInput } from "@/state/mosh-input";
import { sceneCut } from "@/state/scene-cut";
import { DebugFrameView } from "./debug-view";
/**
* Owns the whole post-processing chain of the datamosh scene.
*
* Pass order and why:
*
* RenderPass clean render of the scene
* EffectPass(FXAA) softens the render before the encoder sees it
* VelocityPass screen space motion vectors of camera *and* objects,
* into its own half float target. Disabled unless the
* effect is actually warping, because it draws the
* scene a second time.
* CopyPass (keyframe) disabled, enabled for a single frame when the trigger
* goes down: that one copy *is* the I-frame. Removing
* the keyframes from a video means never refreshing it
* again, so by default it stays frozen for the whole
* gesture; a non-zero keyframe interval re-enables it
* periodically, which is the finite GOP case.
* EffectPass(DataMosh) reprojects the previous output with block quantised
* motion vectors
* CopyPass (feedback) saves the result: this is the P-frame chain the next
* frame reads from
* CopyPass (display) blits the result to the canvas
*
* The display pass looks redundant (it draws exactly what the composer already
* has in its input buffer) and the custom "DisplayEffect" it replaces really
* was, but a terminal pass is structurally required: `autoRenderToScreen` marks
* the last pass as the one drawing to the canvas, and a CopyPass that renders
* to the screen writes to the canvas *instead of* its own target, which would
* silently break the feedback capture. A plain CopyPass is the cheapest thing
* that can sit there.
*/
/**
* How long a gesture runs before the lost sectors appear, in milliseconds.
*
* A stream does not start dropping packets the instant it starts decoding. The
* gesture opens on a clean smear and the packet loss arrives on top of it,
* which also keeps the frozen blocks from being the first thing the eye reads.
*/
const LOST_SECTOR_DELAY = 300;
export class DataMoshManager {
private readonly composer: EffectComposer;
private readonly renderPass: RenderPass;
private readonly effect: DataMoshEffect;
private readonly moshPass: EffectPass;
private readonly velocityPass: VelocityPass;
private readonly keyframePass: CopyPass;
private readonly feedbackPass: CopyPass;
private readonly keyframeTarget: THREE.WebGLRenderTarget;
private readonly feedbackTarget: THREE.WebGLRenderTarget;
private readonly velocitySupported: boolean;
private readonly fxaaPass: EffectPass;
private settings: DataMoshSettings;
private readonly camera: THREE.Camera;
private width = 1;
private height = 1;
private wasActive = false;
private lastKeyframeTime = 0;
private gestureStartTime = -Infinity;
private debugViews: DebugFrameView[] | null = null;
constructor(
private readonly gl: THREE.WebGLRenderer,
scene: THREE.Scene,
camera: THREE.Camera,
settings: DataMoshSettings,
) {
this.settings = settings;
this.camera = camera;
this.velocitySupported = supportsVelocityBuffer(gl);
if (!this.velocitySupported) {
console.warn(
"[DataMosh] No renderable half float target available, falling back to camera-only motion vectors.",
);
}
// The composer's own buffers have to match the feedback targets: CopyPass
// re-types the target it copies into from the composer's frame buffer
// type, so an 8-bit composer would silently undo the half-float history.
this.composer = new EffectComposer(gl, {
frameBufferType: this.velocitySupported ? THREE.HalfFloatType : THREE.UnsignedByteType,
});
this.renderPass = new RenderPass(scene, camera);
this.composer.addPass(this.renderPass);
// FXAA, and first in the chain rather than last, which is where an
// antialiasing pass normally goes.
//
// Two reasons, and the second is the important one. It stands in for the
// optics: no lens resolves an infinitely sharp edge onto a sensor, so the
// softening belongs upstream of the encoder. And running it at the end
// would round off the macroblock edges - the one thing in this picture
// that has to stay hard, because a datamosh is *made* of tiles disagreeing
// with their neighbours along straight vertical and horizontal seams.
// Antialiasing those away would smooth out the effect itself and leave
// only the smear.
//
// FXAA rather than SMAA: it is a single pass over luminance with no lookup
// textures and no edge/blend-weight intermediates, which at 720p is
// effectively free.
this.fxaaPass = new EffectPass(camera, new FXAAEffect());
this.composer.addPass(this.fxaaPass);
this.velocityPass = new VelocityPass(scene, camera);
this.velocityPass.enabled = false;
this.composer.addPass(this.velocityPass);
// Both feedback targets are owned here (autoResize off) so the resolution
// scale can shrink them independently from the composer buffers.
// Match the article demos: the prediction chain is a half-float buffer on
// capable hardware. Repeated round trips through an 8-bit target quantise
// dark linear values until they collapse to black. The camera-only fallback
// remains usable on devices that cannot render to half-float textures.
const frameType = this.velocitySupported
? THREE.HalfFloatType
: THREE.UnsignedByteType;
this.keyframeTarget = createFrameTarget("DataMosh.Keyframe", frameType);
this.feedbackTarget = createFrameTarget("DataMosh.Feedback", frameType);
this.keyframePass = new CopyPass(this.keyframeTarget, false);
this.keyframePass.enabled = false;
this.composer.addPass(this.keyframePass);
this.effect = new DataMoshEffect({ camera, ...settings });
this.effect.setPFrameTexture(this.feedbackTarget.texture);
this.effect.setVelocityTexture(this.velocityPass.renderTarget.texture);
this.moshPass = new EffectPass(camera, this.effect);
this.composer.addPass(this.moshPass);
this.feedbackPass = new CopyPass(this.feedbackTarget, false);
this.composer.addPass(this.feedbackPass);
// The display pass never uses its own target (it always renders to the
// canvas), so it keeps the 1x1 one it is born with.
this.composer.addPass(new CopyPass(undefined, false));
separateStableDepthTexture(this.composer);
// CSS pixels, not the drawing buffer: setSize hands them straight to the
// composer, which resizes the renderer with them.
const size = gl.getSize(new THREE.Vector2());
this.setSize(size.width, size.height);
this.applySettings();
}
/** Renders the whole chain. Must be called from a useFrame with priority. */
render(deltaTime: number): void {
const settings = this.settings;
const now = performance.now();
// The trigger is read straight from the shared store rather than from the
// React settings object. The gesture state changes at input rate and has to
// be in step with the render loop, not with whenever React gets round to
// committing an effect: a late commit used to mean the picture kept moshing
// after the release. Everything below therefore derives from `recover`, a
// single value recomputed here on every frame.
const { pressed, lastReleaseTime } = moshInput.getSnapshot();
const active = settings.effectEnabled && pressed;
const recover = !settings.effectEnabled
? 1
: pressed
? 0
: lastReleaseTime < 0
? 1
: THREE.MathUtils.clamp(
(now - lastReleaseTime) / Math.max(settings.fadeDuration, 1),
0,
1,
);
this.effect.setRecovery(recover);
// The keyframe is captured on the frame the trigger goes down, from the
// clean render. After that the decoder is on its own, unless a finite GOP
// was asked for.
const gestureStart = active && !this.wasActive;
const gopRefresh =
active &&
!gestureStart &&
settings.keyframeInterval > 0 &&
now - this.lastKeyframeTime >= settings.keyframeInterval;
if (gestureStart || gopRefresh) {
this.lastKeyframeTime = now;
}
if (gestureStart) {
this.gestureStartTime = now;
}
// Written here rather than in applySettings, because the delay is a
// property of the gesture and the panel knows nothing about gestures. The
// release does not cancel it: the sectors stay through the recovery fade.
this.effect.setLostDensity(
now - this.gestureStartTime >= LOST_SECTOR_DELAY
? settings.frozenBlocks
: 0,
);
// Nothing samples the keyframe target. The I-frame is expressed entirely by
// the `uKeyframe > 0.5 -> outputColor = inputColor` branch in the shader;
// this copy exists only to fill the debug preview, so off-screen it is a
// full-resolution blit on the one frame that also performs a scene cut -
// the frame least able to afford it.
this.keyframePass.enabled =
(gestureStart || gopRefresh) && settings.debugFrames;
// Only the periodic keyframes reset the picture: the first one is the
// reference the gesture starts drifting away from, and flashing it would
// just make the trigger blink.
this.effect.setKeyframeRefresh(gopRefresh);
// The gate is `recover`, the same value the shader uses: no warp, no need
// for motion vectors, and the pass cannot outlive the fade.
const moshing = recover < 1;
// Either debug view needs the buffer populated even when nothing is
// moshing, otherwise the velocity preview is just a black rectangle until
// the trigger is held - which is exactly when it is least useful to look at.
const debugging =
settings.effectEnabled && (settings.debugFrames || settings.debugMotion);
this.velocityPass.enabled =
settings.motionSource === "velocity" &&
this.velocitySupported &&
(debugging || moshing);
this.wasActive = active;
// A fully recovered picture is a bit-exact passthrough: the shader's first
// statement is `outputColor = inputColor; return;`, and the pass blends with
// BlendFunction.SRC, so the whole full-screen pass exists to reproduce its
// own input. This is a press-and-hold gesture, so that is the overwhelming
// majority of frames.
//
// The feedback copy deliberately keeps running: with the mosh pass off it
// copies the identical image, which is what keeps `pFrame` current for the
// first frame of the next gesture.
this.moshPass.enabled =
settings.effectEnabled && (moshing || settings.debugMotion);
// The datamosh shader is the only thing in the chain that samples depth, so
// when it is not running the composer's per-frame full-screen depth blit is
// copying a buffer nobody will read. The debug preview reads it too.
this.renderPass.needsDepthBlit =
this.moshPass.enabled || settings.debugFrames;
this.composer.render(deltaTime);
this.keyframePass.enabled = false;
this.effect.setKeyframeRefresh(false);
// Unconditional, even when the velocity pass is off: the transforms of this
// frame are what the *next* one measures against, and skipping them would
// make the first frame of a gesture compare against a stale matrix.
this.velocityPass.capturePreviousState();
this.effect.capturePreviousState();
// The cut has to happen *after* the render, and this is the only point in
// the frame where that is true of everything it depends on:
//
// - the keyframe copy that just ran holds the outgoing shot, which is
// what an I-frame is; cutting first would have captured the incoming
// one instead;
// - the feedback target holds the outgoing picture too, so the next frame
// predicts from a shot that is already gone - the whole point;
// - re-capturing the camera state right after the jump keeps the jump
// itself out of the motion field. Without it the next frame would
// measure the teleport as one enormous vector and the picture would be
// thrown off screen in a single step, instead of being dragged by the
// movement of the new shot.
//
// Every keyframe cuts, not just the first one. A keyframe *is* where a shot
// begins - that is what makes it an I-frame rather than a P-frame - so a
// finite GOP is a sequence of shots, and refreshing the reference picture
// without changing what it is a picture of would restate the same shot over
// and over. With an interval set, the gesture becomes a series of melts,
// each one dragging the shot that has just been replaced.
if ((gestureStart || gopRefresh) && settings.sceneCut && sceneCut.run()) {
this.camera.updateMatrixWorld(true);
this.velocityPass.capturePreviousState();
this.effect.capturePreviousState();
}
this.updateDebugViews();
}
setSize(width: number, height: number): void {
this.width = Math.max(1, width);
this.height = Math.max(1, height);
this.composer.setSize(this.width, this.height, false);
this.applyResolutionScale();
// The debug previews bake the aspect ratio into their canvas.
if (this.debugViews) {
this.disposeDebugViews();
this.syncDebugViews();
}
}
updateSettings(settings: DataMoshSettings): void {
const previous = this.settings;
this.settings = settings;
this.applySettings();
if (settings.resolutionScale !== previous.resolutionScale) {
this.applyResolutionScale();
}
}
dispose(): void {
this.disposeDebugViews();
// Disposes every pass (and therefore every render target owned by them),
// the ping-pong buffers and the depth target.
this.composer.dispose();
}
private applySettings(): void {
this.effect.applySettings(this.settings);
// Disabling the passes is the whole "effect enabled" story: no teardown, no
// re-initialisation, and re-enabling picks up exactly where it left off.
const enabled = this.settings.effectEnabled;
this.moshPass.enabled = enabled;
this.feedbackPass.enabled = enabled;
this.fxaaPass.enabled = this.settings.antialias;
this.syncDebugViews();
}
/**
* Both frame targets are measured off the composer's own buffer, never off
* the size handed to `setSize` - which arrives in CSS pixels, from r3f's
* measured rect. Everything else in the chain is sized in drawing-buffer
* pixels, so on any display with a pixel ratio above 1 a feedback target
* built from CSS pixels is smaller than the picture flowing through it, and
* the loop downsamples and upsamples once per frame. Bilinear down then up is
* not the identity: a perfectly still frame dissolves on its own, which reads
* as the effect losing resolution rather than as the bug it is.
*/
private applyResolutionScale(): void {
const scale = THREE.MathUtils.clamp(this.settings.resolutionScale, 0.1, 1);
const buffer = this.composer.inputBuffer;
const width = Math.max(1, Math.round(buffer.width * scale));
const height = Math.max(1, Math.round(buffer.height * scale));
this.keyframeTarget.setSize(width, height);
this.feedbackTarget.setSize(width, height);
// The decode pass still runs full size; only its sources shrink. The
// shader positions its Catmull-Rom and residual taps in *their* texels.
this.effect.setHistoryResolution(width, height);
this.velocityPass.setResolutionScale(scale);
}
private syncDebugViews(): void {
const wanted = this.settings.debugFrames && this.settings.effectEnabled;
if (wanted && !this.debugViews) {
const aspect = this.width / this.height;
this.debugViews = [
new DebugFrameView("iFrame (keyframe)", 0, aspect),
new DebugFrameView("pFrame (feedback)", 1, aspect),
new DebugFrameView("depth", 2, aspect, "depth"),
new DebugFrameView("velocity", 3, aspect, "velocity"),
];
} else if (!wanted) {
this.disposeDebugViews();
}
}
private disposeDebugViews(): void {
this.debugViews?.forEach((view) => view.dispose());
this.debugViews = null;
}
private updateDebugViews(): void {
if (!this.debugViews) return;
const now = performance.now();
const camera = this.camera as THREE.PerspectiveCamera;
this.debugViews[0].update(this.gl, this.keyframeTarget.texture, now);
this.debugViews[1].update(this.gl, this.feedbackTarget.texture, now);
this.debugViews[2].update(this.gl, this.effect.depthTexture, now, camera);
this.debugViews[3].update(
this.gl,
this.velocityPass.renderTarget.texture,
now,
);
}
}
const createFrameTarget = (
name: string,
type: THREE.TextureDataType,
): THREE.WebGLRenderTarget => {
const target = new THREE.WebGLRenderTarget(1, 1, {
minFilter: THREE.LinearFilter,
magFilter: THREE.LinearFilter,
type,
depthBuffer: false,
stencilBuffer: false,
});
target.texture.name = name;
target.texture.generateMipmaps = false;
return target;
};
/** Shape of the composer internals this module has to reach into. */
interface ComposerDepthInternals {
depthTexture: THREE.DepthTexture | null;
depthRenderTarget: THREE.WebGLRenderTarget | null;
passes: Pass[];
}
/**
* Works around a bug in postprocessing's depth plumbing.
*
* As soon as a pass asks for the scene depth (here the EffectPass, because the
* effect declares `EffectAttribute.DEPTH`), `EffectComposer.createDepthTexture`
* attaches a DepthTexture to the input buffer and creates a second "stable"
* target whose depth attachment the passes actually sample. The stable texture
* is built with `depthTexture.clone()` - and `THREE.Texture.copy` copies the
* `Source` by reference. Three keys the GL texture object on the source (plus
* the sampler parameters, identical in a clone), so both attachments end up
* being the *same* GL texture, and the per frame
* `blitFramebuffer(inputBuffer -> depthRenderTarget)` fails with
*
* GL_INVALID_OPERATION: glBlitFramebuffer: Read and write depth stencil
* attachments cannot be the same image
*
* once per frame, forever. Still present in the latest 6.x (6.39.4), which
* clones the texture three times over.
*
* Giving the stable target a DepthTexture of its own is enough: a fresh
* instance owns a fresh `Source`, so read and write attachments are finally two
* different images. The guard makes this a no-op on a version that fixes the
* bug upstream.
*/
const separateStableDepthTexture = (composer: EffectComposer): void => {
const internals = composer as unknown as ComposerDepthInternals;
const inputDepthTexture = internals.depthTexture;
const depthRenderTarget = internals.depthRenderTarget;
const stable = depthRenderTarget?.depthTexture;
if (!inputDepthTexture || !depthRenderTarget || !stable) return;
if (stable.source !== inputDepthTexture.source) return;
const replacement = new THREE.DepthTexture(
depthRenderTarget.width,
depthRenderTarget.height,
);
replacement.name = "DataMosh.StableDepth";
replacement.format = stable.format;
replacement.type = stable.type;
depthRenderTarget.depthTexture = replacement;
for (const pass of internals.passes) {
pass.setDepthTexture(replacement);
}
};
datamosh-fullproject/src/datamosh/post-processing.tsx
import { useEffect, useRef } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import { controls, subscribeControls } from "@/controls";
import { DataMoshManager } from "./manager";
import { DataMoshSettings } from "./effect";
import { moshInput } from "@/state/mosh-input";
/**
* Deep link for the debug views: `?debug=motion`, `?debug=frames`.
*
* Read once at module scope. The bundle only ever runs in the browser, so
* `window` is always there to read from.
*/
const DEBUG_PARAM = new URLSearchParams(window.location.search).get("debug") ?? "";
/**
* The panel's raw values, mapped onto what the effect actually takes.
*
* Annotated on purpose: an unknown or misspelled key is a compile error here,
* which is exactly how the old `effectAmount` typo slipped through. Everything
* that is not a straight copy is a deliberate translation - the macroblock
* switch collapsing to a quantisation of zero, the residual gain carried at ten
* times its real value, the debug deep links.
*/
const readSettings = (): DataMoshSettings => ({
effectEnabled: controls.effectEnabled,
sceneCut: controls.sceneCut,
fadeDuration: controls.fadeDuration,
keyframeInterval: controls.keyframeInterval,
motionSource: controls.motionSource,
motionGain: controls.motionGain,
parallax: controls.parallax,
blockSize: controls.blockSize,
blockiness: controls.macroblocks ? controls.blockiness : 0,
frozenBlocks: controls.frozenBlocks,
lostLayers: controls.lostLayers,
lostLife: controls.lostLife,
lostScale: controls.lostScale,
lostAspect: controls.lostAspect,
lostVariance: controls.lostVariance,
mvPrecision: controls.mvPrecision,
skipThreshold: controls.skipThreshold,
mismatch: controls.mismatch,
residualGain: controls.residualGain / 10,
residualQuant: controls.residualQuant,
antialias: controls.antialias,
resolutionScale: controls.resolutionScale,
debugMotion: controls.debugMotion || DEBUG_PARAM === "motion",
debugScale: controls.debugScale,
debugFrames: controls.debugFrames || DEBUG_PARAM === "frames",
});
/**
* Builds and drives the datamosh post-processing chain.
*
* The chain is created once and updated in place: rebuilding it on every
* control change used to leak render targets, because `removeAllPasses` only
* drops the references without disposing anything.
*
* Nothing here is React state. The panel writes into a plain object and this
* component hands the result to the manager on the same tick - a control change
* therefore reaches the shader without waiting for a commit, and moving a
* slider re-renders nothing. The mosh trigger is one step further removed
* still: the manager polls the shared input store once per frame.
*/
export const PostProcessing = () => {
const gl = useThree((state) => state.gl);
const scene = useThree((state) => state.scene);
const camera = useThree((state) => state.camera);
// Covers container resizes too, not just window ones.
const size = useThree((state) => state.size);
const managerRef = useRef<DataMoshManager | null>(null);
useEffect(() => {
const manager = new DataMoshManager(gl, scene, camera, readSettings());
managerRef.current = manager;
// The latch is an input, not part of the settings object, so `readSettings`
// above does not carry it. The panel is built before React mounts, so a
// latch restored from localStorage was written into `controls` while no
// subscriber existed to hear it - measured: zero `subscribeControls`
// notifications between boot and this effect. Driftpane's `onStateApplied`
// is not the place for it either: it fires per state application, and this
// effect re-runs (and its cleanup drops the latch) whenever the renderer,
// scene or camera is replaced, with no state application in between. A
// mount-time read is what covers both.
moshInput.setLatched(controls.latch);
const unsubscribe = subscribeControls(() => {
// The latch is the one control that does not belong to the settings
// object: it is an input, and it goes to the store the manager polls.
moshInput.setLatched(controls.latch);
manager.updateSettings(readSettings());
});
return () => {
unsubscribe();
// So the store cannot be left holding a trigger for a chain that no
// longer exists.
moshInput.setLatched(false);
manager.dispose();
managerRef.current = null;
};
}, [gl, scene, camera]);
useEffect(() => {
managerRef.current?.setSize(size.width, size.height);
// The two dimensions rather than the object: r3f rebuilds `size` whenever
// its measured rect changes, and that rect includes `top` and `left`, which
// move on scroll. This effect therefore re-fired on every scroll tick with
// identical dimensions - and `setSize` tears down and rebuilds the debug
// frame views when they are on.
}, [size.width, size.height]);
// Priority > 0 takes over the render loop from r3f, which is what lets the
// composer own the frame.
useFrame((_, delta) => {
managerRef.current?.render(delta);
}, 1);
return null;
};
datamosh-fullproject/src/datamosh/shaders.ts
/**
* Fragment shader of the data moshing effect.
*
* The effect emulates what a decoder does when the keyframes of a compressed
* video are removed: the motion vectors of the incoming frames keep being
* applied, but to the wrong picture. Here the "wrong picture" is `pFrame`, the
* previous output of this very effect, and the motion vectors come either from
* a screen space velocity buffer (`uVelocity`, which measures camera *and*
* object movement, like a real block matching search would) or, as a cheaper
* fallback, from an exact reprojection of the depth buffer through the camera
* transform of the previous frame.
*
* The decoder loop being reproduced is `new = warp(previous, mv) + residual`,
* and all three terms are modelled:
*
* warp the block quantised motion vector, snapped to the sub pixel grid
* a codec actually stores (half pel in MPEG-4 Part 2)
* previous pFrame, i.e. content that belongs to another shot
* residual the high frequencies of the *incoming* picture, quantised with a
* dead zone. It is what paints the ghosts of the new scene over the
* old one, and by being re-added on every frame of the chain it is
* also what drives the image towards clipping
*
* Everything that makes it read as a *datamosh* rather than as a liquid smear
* happens on the macroblock grid: the motion vector is evaluated once per
* block, snapped, and then applied to every pixel of that block, so blocks
* slide as rigid tiles and disagree with their neighbours.
*
* Uniforms provided for free by postprocessing's EffectMaterial and used here:
* inputBuffer, resolution, aspect, cameraNear, cameraFar, readDepth(uv),
* getViewZ(depth).
* `readDepth` only returns real data because the effect declares
* EffectAttribute.DEPTH, which makes the EffectPass request the composer's
* depth texture.
*/
export const mainImageShader = /*glsl*/ `
uniform sampler2D pFrame; // previous output of this effect (the P-frame chain)
uniform sampler2D uVelocity; // screen space velocity: .xy = uv/frame, .a = coverage
uniform vec2 uHistoryResolution; // real size of pFrame/uVelocity, which the
// resolution scale can shrink below the pass
uniform float uTime; // performance.now(), milliseconds (lost-region clock only)
uniform float uRecover; // 0 = fully moshed, 1 = clean; computed on the CPU
uniform float uKeyframe; // 1.0 on the frames a fresh keyframe is decoded
// Camera reprojection: the three matrices needed to ask "where was the point
// I am looking at one frame ago".
uniform mat4 uInvViewProjection; // current view projection, inverted
uniform mat4 uPrevViewProjection; // previous frame, full transform
uniform mat4 uPrevViewProjectionRot; // previous orientation, current position
uniform float uMotionSource; // 1.0 = velocity buffer, 0.0 = camera reprojection
uniform float uMotionGain; // artistic exaggeration of the reprojection
uniform float uParallax; // weight of the depth dependent (translation) term
// Macroblocks.
uniform float uBlockSize; // macroblock edge, in pixels
uniform float uBlockiness; // 0 = continuous per pixel warp, 1 = fully quantised blocks
uniform float uFrozenBlocks; // share of coded blocks that lose their motion vector
uniform float uLostLayers; // how many grids of lost regions overlap, 1 to 4
uniform float uMvPrecision; // sub pixel steps per pixel: 1 = full pel, 2 = half pel
uniform float uSkipThreshold; // motion, in pixels, below which a block is not coded at all
uniform float uMismatch; // share of blocks that pick up a neighbour's vector
// Residual.
uniform float uResidualGain; // how much of the incoming picture bleeds through
uniform float uResidualQuant; // quantisation steps; low = coarse, only strong edges survive
uniform float uLostLife; // how long a lost-vector region lasts, in ms
uniform float uLostScale; // region size: bigger value, fewer and larger
uniform float uLostAspect; // >1 wide rectangles, <1 tall ones
uniform float uLostVariance; // 0 = all the same size, 1 = wildly different
// Debug.
uniform float uDebugMotion; // 1 = draw the motion field instead of the picture
uniform float uDebugScale; // pixels per frame that map to full brightness
// --- hashes (Dave Hoskins style: no sin, cheap, well distributed) ------
float hash13(vec3 p3) {
p3 = fract(p3 * 0.1031);
p3 += dot(p3, p3.zyx + 31.32);
return fract((p3.x + p3.y) * p3.z);
}
vec2 hash23(vec3 p3) {
p3 = fract(p3 * vec3(0.1031, 0.1030, 0.0973));
p3 += dot(p3, p3.yzx + 33.33);
return fract((p3.xx + p3.yz) * p3.zy);
}
/**
* Screen space motion of the surface visible at \`sampleUV\`, in uv units per
* frame, derived from the camera transform alone.
*
* The point is unprojected to world space with the depth buffer and then
* projected again with the camera of the previous frame. That is an exact
* reprojection, valid for any movement including roll, rather than the small
* angle approximation of a linearised yaw/pitch/dolly model.
*
* It is projected twice: once with the full previous transform, once with
* the previous orientation but the *current* position. The difference
* between the two results is exactly the parallax, i.e. the part of the
* movement that depends on distance, so uParallax weights that term alone
* (and, past 1, exaggerates it).
*
* This only knows about the camera. Objects that move on their own are
* static to it, which is why the velocity buffer is the default source.
*/
vec2 cameraMotionAt(const in vec2 sampleUV) {
float depth = readDepth(sampleUV);
vec4 ndc = vec4(vec3(sampleUV, depth) * 2.0 - 1.0, 1.0);
vec4 world = uInvViewProjection * ndc;
world /= world.w;
vec4 prevFull = uPrevViewProjection * world;
vec4 prevRot = uPrevViewProjectionRot * world;
// A point that was behind the previous camera plane projects to garbage.
if (prevFull.w <= 0.0 || prevRot.w <= 0.0) {
return vec2(0.0);
}
vec2 uvFull = (prevFull.xy / prevFull.w) * 0.5 + 0.5;
vec2 uvRot = (prevRot.xy / prevRot.w) * 0.5 + 0.5;
return sampleUV - mix(uvRot, uvFull, uParallax);
}
/**
* Whether this point sits inside a region whose motion vectors were lost.
*
* Not a per block coin flip, which is what it used to be: a fixed hash on
* the block id picks the same blocks for the whole gesture, so each one
* sits still from beginning to end and paints a long streak behind it.
* Packet loss does not work like that - it takes out whatever run of
* macroblocks a packet happened to carry, for as long as it takes the next
* one to arrive, and then it is somewhere else entirely.
*
* So: three layers of cells, each placing a rectangle of random size at a
* random offset inside its cell, on its own phase-shifted clock. The layers
* overlap freely, which is what produces the irregular compound shapes, and
* a region only lives a fraction of uLostLife, so it is gone before it has
* time to leave much of a trail.
*/
float lostRegion(const in vec2 uv) {
if (uFrozenBlocks <= 0.0) return 0.0;
// Constant bound with a break rather than a variable one: the loop has
// to be unrollable, and the count is a uniform.
for (int i = 0; i < 4; i++) {
float fi = float(i);
if (fi >= uLostLayers) break;
vec2 cells = vec2(7.0, 5.0) * (1.0 + fi * 1.6) / max(uLostScale, 0.05);
vec2 cellId = floor(uv * cells);
vec2 inCell = fract(uv * cells);
float phase = hash13(vec3(cellId, fi + 5.0));
float t = uTime / max(uLostLife, 16.0) + phase;
float slot = floor(t);
float age = fract(t);
if (hash13(vec3(cellId, slot * 7.0 + fi)) > uFrozenBlocks) continue;
vec2 centre = 0.2 + hash23(vec3(cellId, slot + fi * 23.0)) * 0.6;
// Size is a floor plus a random span, and the span is what the
// variance scales: at zero every region is the same small
// rectangle, at one they range from slivers to most of a cell.
// The aspect term stretches one axis against the other, so the
// proportions can be pushed from tall to wide without touching area.
vec2 stretch = vec2(uLostAspect, 1.0 / max(uLostAspect, 0.05));
vec2 halfSize = (0.05 + hash23(vec3(cellId, slot + fi * 41.0))
* vec2(0.45, 0.26) * uLostVariance) * stretch;
vec2 d = abs(inCell - centre);
if (d.x > halfSize.x || d.y > halfSize.y) continue;
// Short duty cycle: most regions blink out well before their slot
// ends, which is what keeps the streaks short.
if (age > 0.12 + hash13(vec3(cellId, slot + 61.0)) * 0.45) continue;
return 1.0;
}
return 0.0;
}
/**
* Catmull-Rom lookup into the prediction chain.
*
* This is not a quality nicety, it is what stops the picture from
* dissolving while the trigger is held. The chain re-reads its own previous
* output every frame, and the motion vectors are snapped to half a pixel -
* so the offset is *guaranteed* to be fractional, and a fractional bilinear
* fetch blends two texels. Sixty of those a second compound into a blur
* that has nothing to do with the effect: the frame visibly loses
* resolution even when nothing is moving.
*
* Catmull-Rom reconstructs with a negative lobe, so repeated resampling
* keeps its edges instead of averaging them away. It is the same fix a
* temporal anti-aliasing history buffer needs, for exactly the same reason.
* Nine bilinear lookups arranged as a separable 4x4 kernel.
*/
vec3 sampleHistory(const in vec2 uv) {
vec2 texel = uHistoryResolution;
vec2 samplePos = uv * texel;
vec2 texPos1 = floor(samplePos - 0.5) + 0.5;
vec2 f = samplePos - texPos1;
vec2 w0 = f * (-0.5 + f * (1.0 - 0.5 * f));
vec2 w1 = 1.0 + f * f * (-2.5 + 1.5 * f);
vec2 w2 = f * (0.5 + f * (2.0 - 1.5 * f));
vec2 w3 = f * f * (-0.5 + 0.5 * f);
// The middle two taps are folded into one bilinear fetch placed between
// them, which is what brings sixteen samples down to nine.
vec2 w12 = w1 + w2;
vec2 offset12 = w2 / max(w12, vec2(1e-5));
vec2 p0 = (texPos1 - 1.0) / texel;
vec2 p3 = (texPos1 + 2.0) / texel;
vec2 p12 = (texPos1 + offset12) / texel;
vec3 result = vec3(0.0);
result += texture2D(pFrame, vec2(p0.x, p0.y)).rgb * (w0.x * w0.y);
result += texture2D(pFrame, vec2(p12.x, p0.y)).rgb * (w12.x * w0.y);
result += texture2D(pFrame, vec2(p3.x, p0.y)).rgb * (w3.x * w0.y);
result += texture2D(pFrame, vec2(p0.x, p12.y)).rgb * (w0.x * w12.y);
result += texture2D(pFrame, vec2(p12.x, p12.y)).rgb * (w12.x * w12.y);
result += texture2D(pFrame, vec2(p3.x, p12.y)).rgb * (w3.x * w12.y);
result += texture2D(pFrame, vec2(p0.x, p3.y)).rgb * (w0.x * w3.y);
result += texture2D(pFrame, vec2(p12.x, p3.y)).rgb * (w12.x * w3.y);
result += texture2D(pFrame, vec2(p3.x, p3.y)).rgb * (w3.x * w3.y);
// The negative lobe can overshoot, and an overshoot fed back into
// itself diverges. Clamping here costs nothing and bounds the loop.
return clamp(result, 0.0, 1.0);
}
/**
* Stand-in for the quantised DCT residual of the incoming picture.
*
* Besides the motion vectors, a P-frame carries the correction the
* prediction cannot supply: the high frequencies of the new frame, coded in
* 8x8 tiles. Applied against the wrong reference that correction becomes an
* injection of edges and texture belonging to a scene that is not on screen
* - which is where the ghosts of a real mosh come from. And since it is
* summed again on every frame of the feedback chain, it is also what drives a
* held gesture towards white instead of merely deforming it.
*
* A box low pass of the input buffer approximates the frequencies the
* prediction would have captured; what is left over is the residual. The
* quantiser has a dead zone, so flat areas transmit nothing at all.
*/
vec3 residualAt(const in vec2 uv, const in vec2 motion,
const in vec3 current, const in vec3 predicted) {
// Only the *high frequencies* of the incoming picture are transmitted
// on top of the stale one, and only the part of them the prediction
// does not already carry.
//
// Both halves of that matter, and getting either wrong breaks the
// effect in an obvious way:
//
// Taking a plain high pass of the incoming frame, as this did first,
// never settles - the same contour is handed to the loop on every frame
// and integrates until the edges clip, however low the gain.
//
// Taking the full prediction error, as it did next, is worse: that is
// precisely the correction that lands the decoder back on the true
// frame, so the picture converges on the clean render and the stale
// texture the whole effect exists to smear simply disappears. A real
// encoder computes its residual against the *correct* reference, which
// is why feeding it a wrong one produces ghosts rather than a fix.
//
// Subtracting the two high passes keeps the low frequencies of the old
// shot - its colour, its masses, the thing being dragged - and lays the
// edges of the new one over them. It is self-limiting, because once
// those edges are present the term goes to zero.
// Tight on purpose. The high pass keeps everything finer than this
// radius, so a wide one lets whole mid-scale structures through - and
// those carry colour, not just edges. At half a macroblock the new shot
// bled its palette into the old one and the held frame looked
// semi-transparent. A pixel and a half keeps the contours and leaves
// the colour where it belongs, on the picture being dragged.
vec2 r = vec2(1.5) / resolution;
vec3 lowCurrent = texture2D(inputBuffer, uv + vec2(r.x, 0.0)).rgb;
lowCurrent += texture2D(inputBuffer, uv - vec2(r.x, 0.0)).rgb;
lowCurrent += texture2D(inputBuffer, uv + vec2(0.0, r.y)).rgb;
lowCurrent += texture2D(inputBuffer, uv - vec2(0.0, r.y)).rgb;
// Same 1.5-texel ring, but in the history's own texels.
vec2 rh = vec2(1.5) / uHistoryResolution;
vec2 p = uv - motion;
vec3 lowPredicted = texture2D(pFrame, clamp(p + vec2(rh.x, 0.0), 0.002, 0.998)).rgb;
lowPredicted += texture2D(pFrame, clamp(p - vec2(rh.x, 0.0), 0.002, 0.998)).rgb;
lowPredicted += texture2D(pFrame, clamp(p + vec2(0.0, rh.y), 0.002, 0.998)).rgb;
lowPredicted += texture2D(pFrame, clamp(p - vec2(0.0, rh.y), 0.002, 0.998)).rgb;
// Both high passes, as luminance.
float hc = dot(current - lowCurrent * 0.25, vec3(0.299, 0.587, 0.114));
float hp = dot(predicted - lowPredicted * 0.25, vec3(0.299, 0.587, 0.114));
// Only where the incoming frame has *more* local contrast than the
// prediction. Taking the plain difference looked right on paper, but it
// expands to predicted*(1-g) + blur(predicted)*g + g*hc, and that first
// half is an inverted unsharp mask: a blur, re-applied every frame.
// Wherever the new shot was flat it was the only term left, so the held
// picture was quietly smoothed into mush at sixty frames a second.
// Gating on magnitude adds the new edges without ever eroding the old
// ones - and it still cannot accumulate, because once the two agree the
// term is zero.
float residual = abs(hc) > abs(hp) ? hc - hp : 0.0;
// Dead zone quantiser, as in a real encoder: below one step there is no
// coefficient worth sending, so flat areas transmit nothing at all.
// Luma only - a codec quantises chroma far more coarsely, and an RGB
// residual would deposit the new shot's palette on the old picture.
float steps = max(uResidualQuant, 1.0);
return vec3(floor(residual * steps + 0.5) / steps);
}
void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor) {
// Recovery cross fade: 0 = fully moshed, 1 = clean input.
float recover = uRecover;
// Nothing to do once the picture has recovered: skip the whole warp.
// The debug view is exempt - the point of it is to see the field while
// the gesture is *not* running.
if (recover >= 1.0 && uDebugMotion < 0.5) {
outputColor = inputColor;
return;
}
// A keyframe resets the decoder: it carries a full picture, so the
// prediction chain is dropped and this clean frame becomes the new
// reference the next P-frames will drift away from.
if (uKeyframe > 0.5) {
outputColor = inputColor;
return;
}
// The recovery is a gate on the *prediction*, not only a mix on the
// output colour. Letting the blocks keep sliding at full speed while the
// picture dissolves means the feedback chain drags fresh garbage in
// during the very frames that are supposed to be converging; scaling the
// motion and the residual down with the fade makes the smear decelerate
// and come to rest, so the image can only ever get closer to the clean
// frame.
float warp = uDebugMotion > 0.5 ? 1.0 : 1.0 - recover;
// --- macroblock grid ----------------------------------------------
vec2 blocks = max(resolution / max(uBlockSize, 1.0), vec2(1.0));
vec2 blockId = floor(uv * blocks);
vec2 blockUV = (blockId + 0.5) / blocks;
// Motion compensation mismatch: block matching minimises a numeric
// difference, not a semantic one, so it regularly locks onto a block
// that belongs to a different object. Reading the vector one block away
// reproduces that - the tile slides with a movement that is not its
// own, which is the "blocks lifted from somewhere else" look.
float mismatch = step(hash13(vec3(blockId, 23.0)), uMismatch) * uBlockiness;
vec2 neighbour = sign(hash23(vec3(blockId, 29.0)) - 0.5) / blocks;
vec2 mvBlockUV = clamp(blockUV + neighbour * mismatch, vec2(0.0), vec2(1.0));
// The motion vector is evaluated at the block centre; uBlockiness fades
// back to a per pixel evaluation, i.e. the old continuous smear.
vec2 mvUV = mix(uv, mvBlockUV, uBlockiness);
// --- motion vector -------------------------------------------------
// Measured field: already per frame, and already containing the camera
// contribution. The analytic one is an alternative, never a summand -
// adding them would count the camera twice. Where the velocity pass
// rasterised nothing (.a == 0) there is no measurement, so the camera
// reprojection takes over.
vec4 measured = texture2D(uVelocity, mvUV);
float useMeasured = uMotionSource * step(0.5, measured.a);
vec2 motion = mix(cameraMotionAt(mvUV), measured.xy, useMeasured)
* uMotionGain * warp;
// Real motion vectors are not continuous: MPEG-4 Part 2 stores them at
// half pixel precision, H.264 at quarter pixel. Snapping to that grid is
// what makes neighbouring blocks slide by visibly different amounts
// instead of forming a smooth field.
float mvSteps = max(uMvPrecision, 1.0);
vec2 motionPx = motion * resolution;
motion = mix(motionPx, floor(motionPx * mvSteps + 0.5) / mvSteps, uBlockiness)
/ resolution;
// --- skipped and lost blocks ---------------------------------------
float motionPixels = length(motion * resolution);
// Skipped macroblocks are not coded at all: below the threshold the
// encoder sends neither a vector nor a residual, and the decoder just
// keeps whatever the reference held. Static areas therefore stay frozen
// while moving ones smear, and the contrast between those two
// populations of blocks is the visual signature of a datamosh - the
// mechanism behind its ghosting.
// Guarded: at a threshold of zero the smoothstep would have equal edges,
// which is undefined in GLSL and in practice returns 1 on some drivers -
// freezing the entire frame instead of nothing.
float skip = uSkipThreshold <= 0.0
? 0.0
: (1.0 - smoothstep(uSkipThreshold * 0.5, uSkipThreshold, motionPixels))
* uBlockiness;
// Lost vectors are a different failure: the block *is* coded, it simply
// does not move. It still receives its residual.
float lost = lostRegion(blockUV) * uBlockiness;
motion *= 1.0 - max(skip, lost);
// --- debug view -----------------------------------------------------
// Drawn from the vector the effect is actually about to use, after the
// block quantisation, the sub pixel snap, the skip and the mismatch -
// not from the raw velocity buffer. Seeing the raw field would hide
// exactly the stages that decide what the picture does.
//
// Hue is direction, brightness is magnitude, and the grid marks the
// macroblocks. Grey means a block that is not moving: at full recovery
// that is most of the frame, which is the point - those are the skipped
// blocks that will freeze and produce the ghosting.
if (uDebugMotion > 0.5) {
float magnitude = length(motion * resolution);
float angle = atan(motion.y, motion.x);
// Direction around the wheel, magnitude as value.
vec3 wheel = clamp(
abs(mod(angle / 6.2831853 * 6.0 + vec3(0.0, 4.0, 2.0), 6.0) - 3.0) - 1.0,
0.0, 1.0
);
float value = clamp(magnitude / max(uDebugScale, 0.01), 0.0, 1.0);
vec3 debug = mix(vec3(0.12), wheel, step(0.02, value)) * max(value, 0.12);
// Macroblock grid, so the quantisation is visible as such.
vec2 grid = abs(fract(uv * blocks) - 0.5);
float line = 1.0 - step(0.47, max(grid.x, grid.y));
debug = mix(vec3(0.35), debug, line);
outputColor = vec4(debug, 1.0);
return;
}
// The whole block is sampled with the same offset, added to the pixel's
// own uv, so the tile slides as a unit instead of getting pixelated.
// A block that ended up with no motion at all reads its own texel
// exactly, which is both cheaper and lossless - worth the branch
// because with a still camera that is most of the frame.
vec3 moshed = length(motion * resolution) < 0.001
? texture2D(pFrame, uv).rgb
: sampleHistory(uv - motion);
// The residual belongs to the incoming picture and is applied to the
// stale one, exactly like a decoder handed the wrong reference would.
moshed += residualAt(uv, motion, inputColor.rgb, moshed) * uResidualGain * (1.0 - skip) * warp;
// Clipping is not a safety net here, it is part of the effect: the
// residual keeps being re-added on every frame of the chain, so a held
// gesture drifts monotonically into saturation.
moshed = clamp(moshed, 0.0, 1.0);
outputColor = vec4(mix(moshed, inputColor.rgb, recover), 1.0);
}
`;
datamosh-fullproject/src/datamosh/velocity-pass.ts
import { Pass } from "postprocessing";
import * as THREE from "three";
/**
* Screen space velocity buffer.
*
* A codec does not know anything about cameras: its motion vectors come from a
* block matching search between two decoded pictures, so they capture *every*
* movement, including objects that move while the camera stands still. This
* pass is the closest cheap equivalent: the scene is drawn a second time with a
* material that projects each vertex twice, once with the current
* model/view/projection and once with the ones of the previous frame, and
* writes the difference of the two screen positions.
*
* Output layout, RGBA:
* rg screen space motion of that surface point, in uv units per frame,
* positive along the direction the content is travelling
* b unused (kept at 0)
* a coverage: 1 where geometry was rasterised, 0 on the cleared background
*
* The buffer therefore already contains the camera contribution: the effect
* must use it *instead of* the analytic camera motion, never on top of it.
*
* Precision matters here. A typical vector is a handful of pixels, i.e. a few
* thousandths of a uv unit, which an 8 bit per channel target cannot represent
* (its smallest step is 1/255). The target is a half float one; support is
* checked with `supportsVelocityBuffer` before the pass is ever enabled.
*/
/** userData key under which each mesh keeps its previous world matrix. */
const PREVIOUS_MATRIX_KEY = "__dataMoshPreviousMatrixWorld";
/**
* userData key under which each skinned mesh keeps a copy of its bone texture
* as it stood on the previous frame.
*/
const PREVIOUS_BONES_KEY = "__dataMoshPreviousBoneTexture";
/**
* userData key under which each skinned mesh records the bone-texture version
* its mirror was taken from, so an unchanged pose can be skipped.
*/
const PREVIOUS_BONES_VERSION_KEY = "__dataMoshPreviousBoneVersion";
/**
* Safety clamp, in uv units per frame. A tab that was throttled or a geometry
* swap can produce a nonsensical delta for a single frame; 0.25 (a quarter of
* the screen in one frame) is far beyond anything the demo can legitimately
* produce.
*/
const MAX_VELOCITY = 0.25;
const velocityVertexShader = /*glsl*/ `
// Three decides USE_SKINNING from the object being drawn, not from the
// material, so an override material gets the define whether it asked for it
// or not - and a shader that ignores it rasterises every rigged character in
// bind pose. The buffer would then hold a T-shaped patch of vectors sitting
// nowhere near the dancer the viewer can see.
//
// Both poses are skinned: the current one through three's own chunks, the
// previous one through a mirror of last frame's bone texture. Without the
// second half a rigged figure contributes nothing at all to the motion field
// unless the camera or its root moves - the mesh's own matrix never changes
// while it dances - and a shot built around a dancer in front of a locked-off
// camera would produce a completely empty buffer.
#include <skinning_pars_vertex>
#ifdef USE_SKINNING
uniform highp sampler2D uPreviousBoneTexture;
uniform float uHasPreviousBones;
// Same layout three writes: four consecutive texels per bone, packed left
// to right and wrapping by the texture's width.
mat4 getPreviousBoneMatrix(const in float i) {
int size = textureSize(uPreviousBoneTexture, 0).x;
int j = int(i) * 4;
int x = j % size;
int y = j / size;
return mat4(
texelFetch(uPreviousBoneTexture, ivec2(x, y), 0),
texelFetch(uPreviousBoneTexture, ivec2(x + 1, y), 0),
texelFetch(uPreviousBoneTexture, ivec2(x + 2, y), 0),
texelFetch(uPreviousBoneTexture, ivec2(x + 3, y), 0)
);
}
#endif
uniform mat4 uPreviousModelMatrix;
uniform mat4 uPreviousViewProjection;
uniform float uHasPrevious;
varying vec4 vClipCurrent;
varying vec4 vClipPrevious;
void main() {
vec3 transformed = position;
#include <skinbase_vertex>
#include <skinning_vertex>
// Falls back to the current pose, which yields exactly zero limb velocity -
// the right answer on the first frame, and for anything not skinned.
vec3 previousPosition = transformed;
#ifdef USE_SKINNING
if (uHasPreviousBones > 0.5) {
vec4 bindVertex = bindMatrix * vec4(position, 1.0);
vec4 skinned = vec4(0.0);
skinned += getPreviousBoneMatrix(skinIndex.x) * bindVertex * skinWeight.x;
skinned += getPreviousBoneMatrix(skinIndex.y) * bindVertex * skinWeight.y;
skinned += getPreviousBoneMatrix(skinIndex.z) * bindVertex * skinWeight.z;
skinned += getPreviousBoneMatrix(skinIndex.w) * bindVertex * skinWeight.w;
previousPosition = (bindMatrixInverse * skinned).xyz;
}
#endif
vec4 clipCurrent = projectionMatrix * modelViewMatrix * vec4(transformed, 1.0);
vec4 clipPrevious = uPreviousViewProjection * uPreviousModelMatrix * vec4(previousPosition, 1.0);
vClipCurrent = clipCurrent;
// Without a registered previous matrix (first frame, object just added) the
// previous position is the current one, which yields exactly zero velocity
// instead of a delta against uninitialised data.
vClipPrevious = mix(clipCurrent, clipPrevious, uHasPrevious);
gl_Position = clipCurrent;
}
`;
const velocityFragmentShader = /*glsl*/ `
uniform float uMaxVelocity;
varying vec4 vClipCurrent;
varying vec4 vClipPrevious;
void main() {
// Perspective divide has to happen per fragment: interpolating the divided
// value would be wrong for anything that is not parallel to the screen.
vec2 ndcCurrent = vClipCurrent.xy / vClipCurrent.w;
vec2 ndcPrevious = vClipPrevious.xy / vClipPrevious.w;
// ndc spans [-1, 1] while uv spans [0, 1].
vec2 velocity = (ndcCurrent - ndcPrevious) * 0.5;
// A point that was behind the previous camera plane projects to garbage.
if (vClipPrevious.w <= 0.0) {
velocity = vec2(0.0);
}
gl_FragColor = vec4(clamp(velocity, -uMaxVelocity, uMaxVelocity), 0.0, 1.0);
}
`;
/**
* Whether the renderer can use a half float colour attachment, which the
* velocity buffer needs to hold sub pixel motion.
*/
export const supportsVelocityBuffer = (
renderer: THREE.WebGLRenderer,
): boolean =>
renderer.capabilities.isWebGL2 &&
(renderer.extensions.has("EXT_color_buffer_half_float") ||
renderer.extensions.has("EXT_color_buffer_float"));
/**
* A float RGBA texture laid out exactly like the one `Skeleton` builds: four
* texels per bone matrix, nearest filtered and unmipmapped so `texelFetch` gets
* the stored values back rather than an interpolation of them.
*/
const createBoneTexture = (width: number, height: number): THREE.DataTexture => {
const texture = new THREE.DataTexture(
new Float32Array(width * height * 4),
width,
height,
THREE.RGBAFormat,
THREE.FloatType,
);
texture.name = "DataMosh.PreviousBones";
texture.needsUpdate = true;
return texture;
};
export class VelocityPass extends Pass {
readonly renderTarget: THREE.WebGLRenderTarget;
private readonly velocityMaterial: THREE.ShaderMaterial;
/** View projection matrix the camera had on the previous frame. */
private readonly previousViewProjection = new THREE.Matrix4();
private hasPreviousCamera = false;
// Scratch object, reused to avoid a per frame allocation.
private readonly scratchColor = new THREE.Color();
/**
* Bound to the previous-bones sampler for everything that has no history of
* its own. The sampler is only declared in the skinned variant of the shader,
* but a skinned mesh on its very first frame still has to point somewhere
* valid while `uHasPreviousBones` says not to read it.
*/
private readonly emptyBoneTexture = createBoneTexture(4, 4);
private baseWidth = 1;
private baseHeight = 1;
private resolutionScale = 1;
constructor(scene: THREE.Scene, camera: THREE.Camera) {
super("VelocityPass", scene, camera);
// The pass writes to its own target and leaves the ping-pong buffers
// untouched, so the composer must not swap them afterwards.
this.needsSwap = false;
this.renderTarget = new THREE.WebGLRenderTarget(1, 1, {
// Nearest on purpose: a motion vector is a piecewise constant field, and
// interpolating it across a silhouette mixes two unrelated movements (and
// would turn the coverage flag into a meaningless 0.5).
minFilter: THREE.NearestFilter,
magFilter: THREE.NearestFilter,
type: THREE.HalfFloatType,
depthBuffer: true,
stencilBuffer: false,
});
this.renderTarget.texture.name = "DataMosh.Velocity";
this.renderTarget.texture.generateMipmaps = false;
this.velocityMaterial = new THREE.ShaderMaterial({
name: "DataMosh.VelocityMaterial",
vertexShader: velocityVertexShader,
fragmentShader: velocityFragmentShader,
uniforms: {
uPreviousModelMatrix: { value: new THREE.Matrix4() },
uPreviousViewProjection: { value: new THREE.Matrix4() },
uHasPrevious: { value: 0 },
uPreviousBoneTexture: { value: this.emptyBoneTexture },
uHasPreviousBones: { value: 0 },
uMaxVelocity: { value: MAX_VELOCITY },
},
});
// `scene.overrideMaterial` gives every mesh the same material instance, so
// the per mesh previous matrix cannot live in a plain uniform. Three calls
// `material.onBeforeRender` once per object, right before the uniforms are
// uploaded, and hands over the object: that is the one hook that sees both
// the shared material and the individual mesh. Mutating the objects'
// `onBeforeRender` instead would mean touching scene graph nodes this pass
// does not own, and iterating the meshes by hand would mean reimplementing
// frustum culling and render ordering.
this.velocityMaterial.onBeforeRender = (
_renderer,
_scene,
_camera,
_geometry,
object,
) => {
const uniforms = this.velocityMaterial.uniforms;
const previous = object.userData[PREVIOUS_MATRIX_KEY] as
| THREE.Matrix4
| undefined;
if (previous !== undefined && this.hasPreviousCamera) {
(uniforms.uPreviousModelMatrix.value as THREE.Matrix4).copy(previous);
uniforms.uHasPrevious.value = 1;
} else {
uniforms.uHasPrevious.value = 0;
}
// Per object, like the matrix above and for the same reason: one shared
// material, so the pose history cannot live in a plain uniform.
const bones = object.userData[PREVIOUS_BONES_KEY] as
| THREE.DataTexture
| undefined;
const hasBones = bones !== undefined && this.hasPreviousCamera;
uniforms.uPreviousBoneTexture.value = hasBones
? bones
: this.emptyBoneTexture;
uniforms.uHasPreviousBones.value = hasBones ? 1 : 0;
// Match the culling of the real material: the room is seen from the
// inside, so its walls would be missing from the buffer if the override
// material always used the default front side. Three reads `side` again
// for every object, so this is a plain state change, not a recompile.
const source = (object as THREE.Mesh).material;
const first = Array.isArray(source) ? source[0] : source;
if (first !== undefined) {
this.velocityMaterial.side = first.side;
}
// Three only uploads a ShaderMaterial's uniforms once per frame unless it
// is told otherwise; without this flag every mesh would be drawn with the
// first mesh's previous matrix.
this.velocityMaterial.uniformsNeedUpdate = true;
};
}
render(renderer: THREE.WebGLRenderer): void {
const scene = this.scene;
const camera = this.camera;
const background = scene.background;
const overrideMaterial = scene.overrideMaterial;
const shadowMapAutoUpdate = renderer.shadowMap.autoUpdate;
// Kept as a Color rather than a hex so restoring it cannot quantise it.
const clearColor = renderer.getClearColor(this.scratchColor);
const clearAlpha = renderer.getClearAlpha();
// The background is drawn by the renderer itself and would bypass the
// override material, filling the buffer with the clear colour read as a
// motion vector.
scene.background = null;
scene.overrideMaterial = this.velocityMaterial;
renderer.shadowMap.autoUpdate = false;
(
this.velocityMaterial.uniforms.uPreviousViewProjection
.value as THREE.Matrix4
).copy(this.previousViewProjection);
// This is the second time the same graph is drawn this frame, and
// `renderer.render` would re-run `scene.updateMatrixWorld()` to derive
// world matrices that are already correct. That walk recurses into every
// child whether or not it is visible, and this scene is ~955 nodes, 903 of
// them bones belonging to shots that are off screen. Nothing between the
// RenderPass and here touches the graph - only full-screen effect passes,
// each on a scene of its own - so the values it would recompute are the
// ones already there.
//
// Three also de-duplicates `skeleton.update()` and the bone texture upload
// per `info.render.frame`, and a second `render()` defeats it: all 14
// skeletons recompute and re-upload, ~90 KB a frame. Rewinding that counter
// to make the dedupe hit was tried and does not work - `projectObject`
// reads the counter *before* `render()` increments it, and every effect
// pass in between is itself a `renderer.render` that bumps it, so the
// offset depends on how many passes happen to be enabled. Measured: no
// change to frame time, so it is not worth reaching further into three.
const autoUpdateWorld = scene.matrixWorldAutoUpdate;
scene.matrixWorldAutoUpdate = false;
renderer.setRenderTarget(this.renderTarget);
// Coverage is carried by alpha, so the background has to be cleared to a
// fully transparent black rather than to the scene's clear colour.
renderer.setClearColor(0x000000, 0);
renderer.clear(true, true, false);
renderer.render(scene, camera);
scene.matrixWorldAutoUpdate = autoUpdateWorld;
renderer.setClearColor(clearColor, clearAlpha);
renderer.shadowMap.autoUpdate = shadowMapAutoUpdate;
scene.overrideMaterial = overrideMaterial;
scene.background = background;
}
/**
* Records the transforms of this frame so the next one can measure against
* them.
*
* This must run on *every* frame, including the ones where the pass itself is
* disabled: otherwise the first frame of a mosh gesture would compare against
* a matrix from an arbitrarily long time ago and produce one huge, wrong
* vector. It has to run after the scene has been rendered, because that is
* when the world matrices are up to date.
*/
capturePreviousState(): void {
const camera = this.camera;
// Safe to overwrite in place: the pass copies it into the uniform at the
// start of its own render, which has already happened by now.
this.previousViewProjection.multiplyMatrices(
camera.projectionMatrix,
camera.matrixWorldInverse,
);
this.hasPreviousCamera = true;
this.scene.traverse(this.captureObject);
}
/**
* Hoisted rather than written inline at the `traverse` call: a fresh closure
* per frame is 60 short-lived allocations a second for a function that closes
* over nothing but `this`.
*/
private readonly captureObject = (object: THREE.Object3D): void => {
if (!(object as THREE.Mesh).isMesh) return;
const previous = object.userData[PREVIOUS_MATRIX_KEY] as
| THREE.Matrix4
| undefined;
if (previous === undefined) {
object.userData[PREVIOUS_MATRIX_KEY] = object.matrixWorld.clone();
} else {
previous.copy(object.matrixWorld);
}
this.captureBones(object as THREE.SkinnedMesh);
};
/**
* Mirrors a skinned mesh's bone texture so the next frame can skin against
* the pose this one was drawn in.
*
* The skeleton's own texture cannot simply be held onto: three overwrites it
* in place every time the mixer advances, so by the time the next frame reads
* it, it would be describing that frame rather than this one. The copy is
* cheap - a rig this size is a 16x16 texture - and it is the only thing that
* lets the pass see a limb move.
*/
private captureBones(mesh: THREE.SkinnedMesh): void {
if (!mesh.isSkinnedMesh) return;
const skeleton = mesh.skeleton;
// Null until the renderer has drawn the mesh once and built it.
const source = skeleton?.boneTexture;
if (skeleton === undefined || source === null || source === undefined) {
return;
}
const width = source.image.width as number;
const height = source.image.height as number;
let mirror = mesh.userData[PREVIOUS_BONES_KEY] as
| THREE.DataTexture
| undefined;
if (
mirror === undefined ||
mirror.image.width !== width ||
mirror.image.height !== height
) {
mirror?.dispose();
mirror = createBoneTexture(width, height);
mesh.userData[PREVIOUS_BONES_KEY] = mirror;
}
// `Skeleton.update()` bumps the source texture's version, and it only runs
// for a mesh the renderer actually drew. An unchanged version therefore
// means the pose already mirrored *is* this frame's pose, and the copy
// would write back the bytes it wrote last time. That is every frame for
// the two shots that are off screen - the mixers below them are gated on
// visibility, so their matrices genuinely cannot move - and it is 72 KB of
// memcpy plus 14 texture re-uploads each time.
const version = source.version;
if (mesh.userData[PREVIOUS_BONES_VERSION_KEY] === version) return;
mesh.userData[PREVIOUS_BONES_VERSION_KEY] = version;
(mirror.image.data as Float32Array).set(skeleton.boneMatrices);
mirror.needsUpdate = true;
}
/** Renders the buffer at a fraction of the display resolution. */
setResolutionScale(scale: number): void {
this.resolutionScale = THREE.MathUtils.clamp(scale, 0.1, 1);
this.applyResolution();
}
setSize(width: number, height: number): void {
this.baseWidth = Math.max(1, width);
this.baseHeight = Math.max(1, height);
this.applyResolution();
}
dispose(): void {
// The matrices and textures are stored on scene objects this pass does not
// own, so they have to be handed back rather than left behind.
this.scene.traverse((object) => {
const bones = object.userData[PREVIOUS_BONES_KEY] as
| THREE.DataTexture
| undefined;
bones?.dispose();
delete object.userData[PREVIOUS_MATRIX_KEY];
delete object.userData[PREVIOUS_BONES_KEY];
delete object.userData[PREVIOUS_BONES_VERSION_KEY];
});
this.emptyBoneTexture.dispose();
super.dispose();
}
private applyResolution(): void {
this.renderTarget.setSize(
Math.max(1, Math.round(this.baseWidth * this.resolutionScale)),
Math.max(1, Math.round(this.baseHeight * this.resolutionScale)),
);
}
}
datamosh-fullproject/src/main.tsx
import '@niccolofanton/driftpane/theme.css';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { createControlPane } from '@/controls';
import { Preloader } from '@/preloader';
import { Scene } from '@/scene/canvas';
const root = document.getElementById('root');
if (root === null) throw new Error('#root is missing from index.html');
// Before React: the panel is built once and owns its values, so there is no
// mount order to reconcile and StrictMode's double mount cannot duplicate it.
// Opt-in only: the page ships clean (it is embedded in the article), and the
// panel appears with ?debug in the URL.
if (new URLSearchParams(location.search).has('debug')) createControlPane();
createRoot(root).render(
<StrictMode>
<Preloader />
<Scene />
</StrictMode>,
);
datamosh-fullproject/src/preloader.tsx
import { useEffect, useRef } from 'react';
import { useProgress } from '@react-three/drei';
/**
* Drives the overlay declared in `index.html`.
*
* The markup lives there rather than here on purpose: the scene is gated on
* roughly 8 MB of room, rig and sky, and the overlay has to be on screen from
* the very first paint - long before this bundle has been fetched and parsed.
* So React never renders it. It only reports progress into it, and removes
* `body.loading` when the assets are in; the CSS does the fade.
*
* `useProgress` reads three's `DefaultLoadingManager`, which is what drei's
* `useGLTF`, `useFBX` and `useEnvironment` all queue their requests on.
*/
/**
* How long the manager has to stay idle before the load counts as finished.
*
* `active` drops to false the moment the queue empties, and the queue empties
* briefly between waves - the room is preloaded at module scope, while the sky
* and the dancer are requested when their Suspense boundaries first render. A
* short settle window keeps that gap from being mistaken for the end.
*/
const SETTLE_MS = 500;
/**
* Hard ceiling. A 404 or a dropped connection leaves an item queued forever,
* and an overlay that never lifts is a worse failure than a scene that is still
* missing a prop: past this the page is handed over regardless.
*/
const TIMEOUT_MS = 20_000;
export const Preloader = () => {
const { active, progress, total } = useProgress();
const settle = useRef<ReturnType<typeof setTimeout>>(undefined);
// Progress bar. Written straight to the DOM: it changes on every chunk, and
// there is nothing here for React to reconcile.
useEffect(() => {
const fill = document.getElementById('loader-fill');
if (fill !== null) fill.style.transform = `scaleX(${progress / 100})`;
}, [progress]);
useEffect(() => {
const done = () => document.body.classList.remove('loading');
const timeout = setTimeout(done, TIMEOUT_MS);
return () => clearTimeout(timeout);
}, []);
useEffect(() => {
clearTimeout(settle.current);
// `total` guards the initial state, where nothing has been requested yet
// and the manager is idle for the ordinary reason.
if (active || total === 0) return;
settle.current = setTimeout(
() => document.body.classList.remove('loading'),
SETTLE_MS,
);
return () => clearTimeout(settle.current);
}, [active, total]);
return null;
};
datamosh-fullproject/src/scene/camera-controller.tsx
import { useEffect, useMemo, useRef } from "react";
import * as THREE from "three";
import { useFrame, useThree } from "@react-three/fiber";
import { moshInput } from "@/state/mosh-input";
import { SCENE_CENTRE, Shot, shot, SHOT_CAMERA } from "@/state/shot";
/**
* Yaw that aims the camera's -Z axis along `direction`, in the YXZ convention
* the controller writes below.
*/
const yawTowards = (direction: THREE.Vector3): number =>
Math.atan2(-direction.x, -direction.z);
/** Pitch that aims the camera's -Z axis along `direction`. */
const pitchTowards = (direction: THREE.Vector3): number =>
Math.asin(THREE.MathUtils.clamp(direction.y, -1, 1));
/**
* Shortest signed difference between two angles.
*
* Yaw wraps, so a target and a current angle can sit a few degrees apart while
* being on opposite sides of the +/-PI seam. Easing the raw difference then
* sends the camera the long way round at speed.
*/
const shortestAngle = (from: number, to: number): number =>
THREE.MathUtils.euclideanModulo(to - from + Math.PI, Math.PI * 2) - Math.PI;
/** Keeps the camera clear of straight up / straight down, where yaw degenerates. */
const MAX_PITCH = THREE.MathUtils.degToRad(80);
/** The original controller was tuned per frame at 60 Hz. */
const REFERENCE_FPS = 60;
const MAX_FRAME_DELTA = 0.1;
const MOVE_SPEED = 0.05 * REFERENCE_FPS;
/** Preserves a per-frame easing coefficient at any refresh rate. */
const deltaAdjustedEase = (easeAt60Fps: number, delta: number): number =>
1 - Math.pow(1 - easeAt60Fps, delta * REFERENCE_FPS);
/**
* Camera controller component that handles camera rotation
*/
interface CameraControllerProps {
autoRotate?: boolean;
}
export const CameraController = ({ autoRotate = false }: CameraControllerProps) => {
// Selector rather than the whole store: the identity selector re-renders this
// component on every `set()` r3f makes - size (so, every scroll), dpr,
// frameloop - none of which it reads.
const camera = useThree((state) => state.camera);
// The trigger listens on the canvas, not on the window: the control panel is
// an overlay, and a window level mousedown made every slider drag and every
// checkbox click fire the effect.
const canvas = useThree((state) => state.gl.domElement);
// Pointer positions are only read inside useFrame, so they live in refs:
// storing them in state would re-render this (null-rendering) component at
// pointer-event rate.
const mousePosition = useRef({ x: 0, y: 0 });
const touchPosition = useRef({ x: 0, y: 0 });
const isMouseMoving = useRef(false);
const isTouchMoving = useRef(false);
const isMouseOnScreen = useRef(false);
const lastMouseMoveTime = useRef(0);
const mouseIdleTimeout = 2000;
const centralPoint = SCENE_CENTRE;
const isInitialized = useRef(false);
const initializationTime = useRef(0);
const movement = useRef({
forward: false,
backward: false,
left: false,
right: false,
});
// The camera's orientation, owned here instead of being read back from
// `camera.rotation` every frame.
//
// Reading it back cannot work. The controller also eased the quaternion
// towards the centre of the room, and three keeps the two representations in
// sync both ways (`Object3D` binds `rotation._onChange` and
// `quaternion._onChange` to each other), so every frame the Euler angles were
// re-derived from the quaternion in the default XYZ order - where yaw is
// `asin(m13)` and therefore confined to +/-90 degrees, with anything beyond
// expressed as a pitch/roll flip instead. A scene cut swings the camera well
// past that, so the angles read back bore no relation to the ones written and
// the two controls chased each other: the spin the user saw.
//
// Holding the state here and writing it out in YXZ order - yaw first, so it
// has the whole circle to itself - removes the flip and the fight at once.
const yaw = useRef(0);
const pitch = useRef(0);
const orientationReady = useRef(false);
// Which shot is up, and the field of view the room's shot is framed with.
// Both are read inside useFrame, so neither can be state.
const activeShot = useRef<Shot>(shot.current);
const defaultFov = useRef<number | null>(null);
// Reused every frame; the controller runs on every one of them.
const scratch = useMemo(
() => ({
forward: new THREE.Vector3(),
toCentre: new THREE.Vector3(),
right: new THREE.Vector3(),
offset: new THREE.Vector3(),
up: new THREE.Vector3(0, 1, 0),
}),
[],
);
useEffect(() => {
const handleMouseMove = (event: MouseEvent) => {
mousePosition.current = {
x: (event.clientX / window.innerWidth) * 2 - 1,
y: -(event.clientY / window.innerHeight) * 2 + 1,
};
isMouseMoving.current = true;
isMouseOnScreen.current = true;
lastMouseMoveTime.current = Date.now();
// Recovery from a lost pointerup: releasing the button over a native
// widget, a context menu or outside the document never delivers the
// event, and the trigger would stay down forever. Any movement with no
// button held proves nothing is pressed any more.
if (event.buttons === 0) {
moshInput.release("pointer");
}
};
const handleTouchMove = (event: TouchEvent) => {
if (event.touches.length > 0) {
const touch = event.touches[0];
touchPosition.current = {
x: (touch.clientX / window.innerWidth) * 2 - 1,
y: -(touch.clientY / window.innerHeight) * 2 + 1,
};
isTouchMoving.current = true;
}
};
// Pointer, touch and spacebar all drive the same shared mosh input store,
// which the post-processing pipeline reads. Each one owns its own token, so
// letting go of one never cancels a gesture another one is still holding.
//
// The "down" half is bound to the canvas so that interacting with the
// control panel does not trigger the effect; the "up" half stays global,
// otherwise dragging off the canvas would leave the trigger stuck.
const handleTouchStart = () => {
isTouchMoving.current = true;
moshInput.press("touch");
};
const handleTouchEnd = () => {
isTouchMoving.current = false;
moshInput.release("touch");
};
const handlePointerDown = (event: PointerEvent) => {
// Secondary buttons open menus and never deliver a matching up event.
if (event.button !== 0) return;
moshInput.press("pointer");
};
const handlePointerUp = () => {
moshInput.release("pointer");
};
const handleMouseEnter = () => {
isMouseOnScreen.current = true;
};
const handleMouseLeave = () => {
isMouseOnScreen.current = false;
};
const handleKeyDown = (event: KeyboardEvent) => {
// Ignore OS auto-repeat: holding a key fires keydown continuously.
if (event.repeat) return;
switch (event.code) {
case "KeyW":
movement.current.forward = true;
break;
case "KeyS":
movement.current.backward = true;
break;
case "KeyA":
movement.current.left = true;
break;
case "KeyD":
movement.current.right = true;
break;
case "Space":
moshInput.press("keyboard");
break;
}
};
const handleKeyUp = (event: KeyboardEvent) => {
switch (event.code) {
case "KeyW":
movement.current.forward = false;
break;
case "KeyS":
movement.current.backward = false;
break;
case "KeyA":
movement.current.left = false;
break;
case "KeyD":
movement.current.right = false;
break;
case "Space":
moshInput.release("keyboard");
break;
}
};
// A key/button held while the window loses focus (or the tab is hidden)
// never delivers its keyup/pointerup, which would leave the input stuck down.
const releaseAllInput = () => {
moshInput.releaseAll();
movement.current.forward = false;
movement.current.backward = false;
movement.current.left = false;
movement.current.right = false;
isTouchMoving.current = false;
};
const handleVisibilityChange = () => {
if (document.hidden) releaseAllInput();
};
window.addEventListener("mousemove", handleMouseMove, { passive: true });
window.addEventListener("touchmove", handleTouchMove, { passive: true });
// Explicitly passive. Chrome's "passive by default" intervention covers
// touchstart only on window, document and body - on an element target the
// default is still non-passive, which makes the canvas a scroll-blocking
// target and holds the compositor until this main thread, busy with the
// whole composer chain, has run the handler. None of these three calls
// preventDefault.
canvas.addEventListener("touchstart", handleTouchStart, { passive: true });
window.addEventListener("touchend", handleTouchEnd, { passive: true });
window.addEventListener("touchcancel", handleTouchEnd, { passive: true });
canvas.addEventListener("pointerdown", handlePointerDown);
window.addEventListener("pointerup", handlePointerUp);
window.addEventListener("pointercancel", handlePointerUp);
window.addEventListener("mouseenter", handleMouseEnter);
window.addEventListener("mouseleave", handleMouseLeave);
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp);
window.addEventListener("blur", releaseAllInput);
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("touchmove", handleTouchMove);
canvas.removeEventListener("touchstart", handleTouchStart);
window.removeEventListener("touchend", handleTouchEnd);
window.removeEventListener("touchcancel", handleTouchEnd);
canvas.removeEventListener("pointerdown", handlePointerDown);
window.removeEventListener("pointerup", handlePointerUp);
window.removeEventListener("pointercancel", handlePointerUp);
window.removeEventListener("mouseenter", handleMouseEnter);
window.removeEventListener("mouseleave", handleMouseLeave);
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("keyup", handleKeyUp);
window.removeEventListener("blur", releaseAllInput);
document.removeEventListener("visibilitychange", handleVisibilityChange);
releaseAllInput();
};
}, [canvas]);
useEffect(() => {
if (camera && !isInitialized.current) {
// Set initial camera rotation more gently
const direction = new THREE.Vector3().subVectors(centralPoint, camera.position).normalize();
camera.lookAt(camera.position.clone().add(direction));
isInitialized.current = true;
initializationTime.current = Date.now();
}
}, [camera, centralPoint]);
/**
* The cut a melt gesture triggers: the shot the effect will smear has to be a
* different shot, not the same one from marginally further along.
*
* The camera is swung around the centre of the room by a large angle instead
* of being dropped at a fixed list of viewpoints. Two reasons: the room is a
* loaded model this component knows nothing about, and an orbit at a bounded
* radius provably stays inside the shell the camera can already reach on its
* own, so no cut can end up inside a wall. The radius and the height are
* nudged as well, so consecutive cuts on the same side never frame alike.
*
* The whole transform is applied at once, orientation included: the camera
* keeps looking at the centre, which means the next frame measures the motion
* of the *new* shot rather than the slew of a camera correcting itself.
*/
useEffect(() => {
if (!camera) return;
const { offset, toCentre, up } = scratch;
const perspective = camera as THREE.PerspectiveCamera;
if (defaultFov.current === null && perspective.isPerspectiveCamera) {
defaultFov.current = perspective.fov;
}
/** Adopts the shot's field of view, or gives the camera its own back. */
const setFieldOfView = (value: number | undefined) => {
const wanted = value ?? defaultFov.current;
if (!perspective.isPerspectiveCamera || wanted === null) return;
if (wanted === undefined || perspective.fov === wanted) return;
perspective.fov = wanted;
perspective.updateProjectionMatrix();
};
return shot.subscribe((current) => {
activeShot.current = current;
// The two limbo shots are composed rather than explored: the camera is
// placed outright and then holds still, so the only thing moving in frame
// is the subject. Position and orientation are written in the same breath
// for the same reason the orbit below does it - a camera still swinging
// into place on the frame after the cut hands the effect the motion of
// the swing instead of the motion of the new shot.
if (current !== "room") {
const pose = SHOT_CAMERA[current];
setFieldOfView(pose.fov);
camera.position.set(...pose.position);
toCentre.set(...pose.target).sub(camera.position).normalize();
yaw.current = yawTowards(toCentre);
pitch.current = THREE.MathUtils.clamp(
pitchTowards(toCentre),
-MAX_PITCH,
MAX_PITCH,
);
camera.rotation.set(pitch.current, yaw.current, 0, "YXZ");
return;
}
setFieldOfView(undefined);
// Never less than a quarter turn: below that the two shots still share
// too much of the frame and the melt reads as a glitch on one image
// rather than as two images fighting.
const angle =
(Math.random() < 0.5 ? -1 : 1) *
THREE.MathUtils.degToRad(75 + Math.random() * 95);
offset.subVectors(camera.position, centralPoint);
const distance = Math.hypot(offset.x, offset.z);
// Straight above the centre there is no horizontal direction to rotate;
// any axis will do, and this one keeps the maths below unbranched.
if (distance < 1e-3) {
offset.set(0, offset.y, 1);
}
const radius = THREE.MathUtils.clamp(
Math.max(distance, 1) * (0.75 + Math.random() * 0.7),
1.3,
2.8,
);
const height = THREE.MathUtils.clamp(
centralPoint.y + offset.y + (Math.random() - 0.5) * 1.2,
1,
2.4,
);
// The look-around the camera is currently holding, measured against the
// view it would have if it framed the centre from where it stands. Only
// the offset is carried across the jump: the framing itself is rebuilt
// from the new position, which is what stops the cut from being undone.
toCentre.subVectors(centralPoint, camera.position).normalize();
const heldYaw = shortestAngle(yawTowards(toCentre), yaw.current);
const heldPitch = pitch.current - pitchTowards(toCentre);
offset.y = 0;
offset.normalize().applyAxisAngle(up, angle).multiplyScalar(radius);
camera.position.set(
centralPoint.x + offset.x,
height,
centralPoint.z + offset.z,
);
// Orientation is applied in the same breath as the position. A camera
// left to swing round and find the centre over the following frames would
// hand the effect the motion of that swing instead of the motion of the
// new shot - and the swing is exactly what the cut exists to avoid.
toCentre.subVectors(centralPoint, camera.position).normalize();
yaw.current = yawTowards(toCentre) + heldYaw;
pitch.current = THREE.MathUtils.clamp(
pitchTowards(toCentre) + heldPitch,
-MAX_PITCH,
MAX_PITCH,
);
camera.rotation.set(pitch.current, yaw.current, 0, "YXZ");
});
}, [camera, centralPoint, scratch]);
useFrame((state, delta) => {
if (camera) {
// A suspended tab must not turn its first resumed frame into a teleport.
const frameDelta = Math.min(delta, MAX_FRAME_DELTA);
const currentTime = Date.now();
const timeSinceInit = currentTime - initializationTime.current;
// Don't start automatic behavior immediately - wait 2 seconds after initialization
if (timeSinceInit < 2000) {
return;
}
/** Walks the camera on the horizontal plane it is currently facing. */
const walk = () => {
const { forward, right } = scratch;
camera.getWorldDirection(forward);
forward.y = 0;
forward.normalize();
right.crossVectors(camera.up, forward).normalize();
if (movement.current.forward) {
camera.position.addScaledVector(forward, MOVE_SPEED * frameDelta);
}
if (movement.current.backward) {
camera.position.addScaledVector(forward, -MOVE_SPEED * frameDelta);
}
if (movement.current.left) {
camera.position.addScaledVector(right, (MOVE_SPEED * frameDelta) / 2);
}
if (movement.current.right) {
camera.position.addScaledVector(right, (-MOVE_SPEED * frameDelta) / 2);
}
};
// A limbo shot does not use the framing logic below, which eases the aim
// back towards the centre of the *room*: there it would quietly undo the
// pose the cut set, and would do it as a slow drift - the one kind of
// camera motion this effect cannot afford to have running for free.
//
// Instead the shot keeps its own subject centred. Walking is free to move
// the camera anywhere, because it only happens while a key is held and so
// cannot drift, and the aim is then rebuilt from wherever it ended up.
// Without that, stepping sideways slid the whole line out of frame and
// left the lens pointing at empty backdrop.
const current = activeShot.current;
if (current !== "room") {
walk();
const { toCentre } = scratch;
toCentre.set(...SHOT_CAMERA[current].target).sub(camera.position);
// Standing exactly on the subject has no direction to look along.
if (toCentre.lengthSq() > 1e-6) {
toCentre.normalize();
yaw.current = yawTowards(toCentre);
pitch.current = THREE.MathUtils.clamp(
pitchTowards(toCentre),
-MAX_PITCH,
MAX_PITCH,
);
camera.rotation.set(pitch.current, yaw.current, 0, "YXZ");
}
return;
}
if (currentTime - lastMouseMoveTime.current > mouseIdleTimeout) {
isMouseMoving.current = false;
}
const minRotationX = THREE.MathUtils.degToRad(-50);
const maxRotationX = THREE.MathUtils.degToRad(50);
const minRotationY = THREE.MathUtils.degToRad(-45);
const maxRotationY = THREE.MathUtils.degToRad(45);
const { forward, toCentre } = scratch;
// Adopt whatever the initialisation effect's lookAt produced, rather than
// snapping away from it on the first frame past the settling delay.
if (!orientationReady.current) {
camera.getWorldDirection(forward);
yaw.current = yawTowards(forward);
pitch.current = pitchTowards(forward);
orientationReady.current = true;
}
// Framing the centre of the room is the resting pose, and every target
// below is an offset from it. Deriving it from the geometry each frame is
// what replaces the old slerp - and what makes a cut stick, since after
// the jump it simply resolves to the view from the new position.
toCentre.subVectors(centralPoint, camera.position).normalize();
const baseYaw = yawTowards(toCentre);
const basePitch = pitchTowards(toCentre);
let yawOffset: number;
let pitchOffset: number;
let idle = false;
if (
autoRotate &&
!isMouseMoving.current &&
!isTouchMoving.current
) {
const time = state.clock.elapsedTime;
const noiseX = Math.sin(time * 0.7) * 0.5 + Math.sin(time * 1.3) * 0.5;
const noiseY = Math.sin(time * 0.5) * 0.5 + Math.cos(time * 1.1) * 0.5;
pitchOffset = THREE.MathUtils.lerp(
minRotationX,
maxRotationX,
(noiseX + 1) * 0.5,
);
yawOffset = THREE.MathUtils.lerp(
minRotationY,
maxRotationY,
(noiseY + 1) * 0.5,
);
} else if (isMouseMoving.current || isTouchMoving.current) {
const position = isTouchMoving.current
? touchPosition.current
: mousePosition.current;
yawOffset = position.x * 0.5;
pitchOffset = -position.y * 0.3;
} else {
// Nothing is driving the camera: drift back to the resting pose, at the
// same couple of percent per frame the quaternion easing used to.
yawOffset = 0;
pitchOffset = 0;
idle = true;
}
const yawEase = idle ? 0.02 : 0.05;
const pitchEase = idle
? 0.02
: (isMouseOnScreen.current && isMouseMoving.current) ||
isTouchMoving.current
? 0.05
: 0.03;
const adjustedYawEase = deltaAdjustedEase(yawEase, frameDelta);
const adjustedPitchEase = deltaAdjustedEase(pitchEase, frameDelta);
yaw.current +=
shortestAngle(yaw.current, baseYaw + yawOffset) * adjustedYawEase;
pitch.current +=
(THREE.MathUtils.clamp(
basePitch + pitchOffset,
-MAX_PITCH,
MAX_PITCH,
) -
pitch.current) *
adjustedPitchEase;
camera.rotation.set(pitch.current, yaw.current, 0, "YXZ");
walk();
}
});
// UI Elements
return null;
};
datamosh-fullproject/src/scene/canvas.tsx
import { Suspense, lazy, useEffect, useState } from 'react';
import * as THREE from 'three';
import { Canvas } from '@react-three/fiber';
import { useControl } from '@/controls';
import { PostProcessing } from '@/datamosh/post-processing';
import { CameraController } from './camera-controller';
import { MorphingShape } from './morphing-shape';
import { Room } from './room';
import { Dancer, OnlyIn, ShotStage } from './shots';
/**
* Loaded only when the overlay is switched on.
*
* A static import put r3f-perf and its dependency graph - stitches, radix
* icons, zustand, drei's Text and troika-three-text, plus a base64 font - into
* the first-load bundle of every visit, and stitches injects its stylesheet at
* module scope, so the cost was paid on the critical path of a page whose whole
* job is to get a WebGL scene running. The control defaults to off in a folder
* that starts collapsed.
*/
const Perf = lazy(() =>
import('r3f-perf').then((module) => ({ default: module.Perf })),
);
/**
* Drawing buffer resolution: the display's own, capped at two.
*
* Given as a range rather than a number so r3f reads `devicePixelRatio` itself
* and keeps up with it - it changes when a window is dragged between a laptop
* screen and an external monitor, and a value sampled once at mount does not.
*
* This replaces a buffer pinned to 720p. Worth knowing what that gave up: the
* camera being simulated records 720p, and a real recording is soft because it
* was *captured* soft, so the softness belonged upstream of the noise, the
* compression and the grade. It also meant the datamosh's macroblocks landed on
* the grid of the recorded frame rather than on the grid of whatever monitor
* happens to be showing it - at native resolution an 8 px block is a third of
* the size on screen that it was, so the tiles read smaller and finer. The
* Macroblock (px) control is the dial that compensates.
*/
const DPR_RANGE: [number, number] = [1, 2];
/** The shots the room's point light is wanted in. Module scope: stable identity. */
const LIT_SHOTS = ['room'] as const;
/** A touch drag on the view must not scroll the page. */
const preventScroll = (event: TouchEvent) => event.preventDefault();
export const Scene = () => {
const [frameloop, setFrameloop] = useState<'always' | 'never'>('always');
const [canvas, setCanvas] = useState<HTMLCanvasElement | null>(null);
// The only two controls that have to be React state: one mounts a component,
// one is a prop. Everything else the panel drives is read imperatively.
const showPerf = useControl((values) => values.showPerf);
const autoRotate = useControl((values) => values.autoRotate);
// Stop rendering while the tab is in the background.
useEffect(() => {
const onVisibilityChange = () =>
setFrameloop(document.hidden ? 'never' : 'always');
document.addEventListener('visibilitychange', onVisibilityChange);
return () =>
document.removeEventListener('visibilitychange', onVisibilityChange);
}, []);
/**
* Bound to the canvas rather than to `document`. `{ passive: false }` is
* required for `preventDefault` to have any effect, and a non-passive
* `touchmove` listener opts its target out of the browser's compositor fast
* path: every touch move has to round-trip through the main thread, which is
* the one running the composer chain. On `document` that applied to the whole
* page, control panel included, which could then never be scrolled.
*/
useEffect(() => {
if (canvas === null) return;
canvas.addEventListener('touchmove', preventScroll, { passive: false });
return () => canvas.removeEventListener('touchmove', preventScroll);
}, [canvas]);
return (
<Canvas
flat
dpr={DPR_RANGE}
frameloop={frameloop}
camera={{
position: [0, 2, 2],
// Wide, the way a body-worn camera is wide: a frustum, not a lens
// distortion. Bending a 50 degree render into a fisheye gives a curved,
// cropped picture rather than a wide one.
fov: 79,
near: 0.1,
// Set by the dancers' floor, not by the room. A plane cut off at the
// far distance shows its edge as a hard line across the frame just
// below the horizon; pushing the plane out far enough that the edge
// lands on the horizon itself is what hides it. Near stays at 0.1, so
// the depth precision is barely affected - it is the near plane that
// governs it, not the far one.
far: 200,
}}
// Depth convention of this pipeline: standard (hyperbolic) perspective
// depth in [0, 1], as written by the default depth buffer. The datamosh
// EffectPass declares EffectAttribute.DEPTH, so the composer attaches a
// DepthTexture to its input buffer and hands it to the effect, whose
// shader linearises it (readDepth / getViewZ) to drive the parallax of
// the camera-only motion vectors. The encoding must therefore stay the
// default one: `logarithmicDepthBuffer` is deliberately off (it would
// make the sampled value logarithmic, and with near .1 / far 200 there is
// no precision problem that would justify it), and `depth` stays enabled
// so both the composer buffers and the velocity pass get a real depth
// attachment to test against.
gl={{
powerPreference: 'high-performance',
alpha: false,
antialias: false,
stencil: false,
depth: true,
outputColorSpace: THREE.SRGBColorSpace,
}}
onCreated={(state) => {
// The composer owns the frame, so r3f must not clear it.
state.gl.autoClear = false;
setCanvas(state.gl.domElement);
}}
>
{/*
The sky is what the room's openings look out onto, and it is also the
scene's image based lighting. Both matter to the effect: the doorways
stop being flat dark holes, which gives the smear a high contrast edge
to drag, and the subject picks up the sky's colour instead of reading
as a shape pasted onto the render.
`ShotStage` owns it because the backdrop is per shot - sky in the
room, flat grey behind the dancers - while the lighting it provides
stays on throughout. It is also what advances the shot on a cut.
Its own Suspense boundary so the 4 MB file does not hold back the rest
of the scene while it decodes.
*/}
<Suspense fallback={null}>
<ShotStage />
</Suspense>
<Room />
<CameraController autoRotate={autoRotate} />
<MorphingShape />
{/* Same reasoning: 3 MB of rig and animation, decoded on its own. */}
<Suspense fallback={null}>
<Dancer />
</Suspense>
{/*
The point light is the room's, and only the room's.
It is a point at a fixed place, so its falloff makes near objects
brighter than far ones - correct for a lamp in a room, wrong for a row
of seven identical dancers, where it lit the lead noticeably harder
than the ones at the ends and the line stopped reading as one troupe.
The sky is an infinite environment and lights every one of them the
same, which is why that shot is left to it alone.
*/}
<OnlyIn shots={LIT_SHOTS}>
<pointLight position={[0, 0, 5]} intensity={2000} distance={5.4} castShadow />
</OnlyIn>
<ambientLight intensity={1} />
<PostProcessing />
{showPerf && (
<Suspense fallback={null}>
<Perf position="bottom-right" />
</Suspense>
)}
</Canvas>
);
};
datamosh-fullproject/src/scene/checker-texture.ts
import * as THREE from "three";
/**
* Resolution of one checker tile.
*
* Two texels would describe the pattern exactly and be useless. A plane repeats
* the tile tens or hundreds of times, so the uv derivatives are large
* everywhere and the hardware picks a low mip almost immediately - and mip
* level 1 of a 2x2 texture is a single texel holding the average of the two
* tones. The surface comes out a flat grey with no squares in it anywhere,
* which is exactly what a first attempt at this produces.
*
* At 128 the tile has six mip levels that still contain a checker, so it stays
* a checker up close and dissolves into its own average only in the distance,
* where that is the right answer.
*/
const CHECKER_TILE = 128;
export interface CheckerOptions {
light: string;
dark: string;
/** How many times the two-by-two tile repeats across the surface. */
repeat: number;
anisotropy: number;
}
/**
* A checkerboard, drawn rather than loaded.
*
* Mipmapped and anisotropically filtered: a checker running to the horizon is
* the classic aliasing case, and a nearest-filtered one would boil into moire
* long before it got there. Which matters here beyond looking bad - a moire
* pattern is high-frequency detail that changes completely from frame to frame,
* and the motion field would be measuring noise.
*/
export const createCheckerTexture = ({
light,
dark,
repeat,
anisotropy,
}: CheckerOptions): THREE.DataTexture => {
const tones = [new THREE.Color(light), new THREE.Color(dark)].map((colour) =>
[colour.r, colour.g, colour.b].map((c) => Math.round(c * 255)),
);
const half = CHECKER_TILE / 2;
const data = new Uint8Array(CHECKER_TILE * CHECKER_TILE * 4);
for (let y = 0; y < CHECKER_TILE; y++) {
for (let x = 0; x < CHECKER_TILE; x++) {
const tone = tones[((x < half ? 0 : 1) + (y < half ? 0 : 1)) % 2];
const i = (y * CHECKER_TILE + x) * 4;
data[i] = tone[0];
data[i + 1] = tone[1];
data[i + 2] = tone[2];
data[i + 3] = 255;
}
}
const texture = new THREE.DataTexture(data, CHECKER_TILE, CHECKER_TILE);
texture.name = "Checker";
// The two tones were written as sRGB bytes, so they have to be declared as
// such or three will treat them as linear and the surface comes out too
// bright.
texture.colorSpace = THREE.SRGBColorSpace;
texture.wrapS = THREE.RepeatWrapping;
texture.wrapT = THREE.RepeatWrapping;
texture.repeat.setScalar(repeat);
texture.magFilter = THREE.LinearFilter;
texture.minFilter = THREE.LinearMipmapLinearFilter;
texture.generateMipmaps = true;
texture.anisotropy = anisotropy;
texture.needsUpdate = true;
return texture;
};
datamosh-fullproject/src/scene/morphing-shape.tsx
import { useRef, useEffect, useMemo } from "react";
import * as THREE from "three";
import { useFrame } from "@react-three/fiber";
import { SCENE_CENTRE, Shot, shot } from "@/state/shot";
/**
* The subject of the room shot: one emissive knot, turning in place.
*
* The colour is doing work here. The smear is only visible where there are
* distinct colour values to stretch, so a saturated emissive surface against
* the muted room reads far better than the room's own palette, and the thin
* knot gives the macroblocks fine lines to tear.
*/
export const MorphingShape = () => {
const meshRef = useRef<THREE.Mesh>(null);
// Built once, not per render, and owned here rather than by r3f (which only
// disposes what it created itself), so it is released by hand on unmount.
const geometry = useMemo(() => new THREE.TorusKnotGeometry(0.4, 0.15, 128, 32), []);
useEffect(() => () => geometry.dispose(), [geometry]);
useEffect(() => {
const show = (current: Shot) => {
if (meshRef.current !== null) meshRef.current.visible = current === "room";
};
show(shot.current);
return shot.subscribe(show);
}, []);
useFrame((_, delta) => {
const mesh = meshRef.current;
if (mesh === null) return;
// One `set` rather than two component writes. Each write to an Euler fires
// Object3D's change callback, which runs `quaternion.setFromEuler` - six
// trig calls - so writing x and y separately computed the quaternion twice
// and threw the first result away. `set` assigns all three and fires once.
const step = delta * 1.2;
const rotation = mesh.rotation;
rotation.set(rotation.x + step, rotation.y + step, rotation.z);
});
return (
<mesh ref={meshRef} position={SCENE_CENTRE} geometry={geometry}>
<meshStandardMaterial
color="#0066ff"
emissive="#0066ff"
emissiveIntensity={0.3}
roughness={0.35}
metalness={0.1}
/>
</mesh>
);
};
datamosh-fullproject/src/scene/room.tsx
import { useEffect, useRef } from "react";
import * as THREE from "three";
import { useGLTF } from "@react-three/drei";
import { GLTF } from "three-stdlib";
import { SCENE_CENTRE, shot } from "@/state/shot";
/**
* Baked room, exported with `npx gltfjsx@6.5.3 backroom.glb -T -R 2048 -f png -t`.
*
* Every mesh in the file sits at the same offset, so the offset lives on the
* group and the meshes are a plain node -> material table. The generated
* component listed all seven inline with the offset repeated on each one.
*/
const MODEL_URL = `${import.meta.env.BASE_URL}models/backroom-transformed.glb`;
const MODEL_OFFSET: [number, number, number] = [-8.981, 4.681, -8.96];
const PARTS: readonly (readonly [node: string, material: string])[] = [
["ligths", "Material.058"],
["ligths001", "Material.059"],
["ligths003", "Material.060"],
["walls003_Baked", "walls.003_Baked"],
["roof_grid_Baked", "roof grid_Baked"],
["roof003_Baked", "roof.003_Baked"],
["floor004_Baked", "floor.004_Baked"],
];
type RoomGLTF = GLTF & {
nodes: Record<string, THREE.Mesh>;
materials: Record<string, THREE.Material>;
};
/**
* The room, resized by a scene cut.
*
* Changing the scale of the room changes every wall, edge and light panel in
* frame at once, which is a far bigger disagreement between the two shots than
* a camera move alone can produce - and the melt lives on that disagreement.
*
* Two details matter. The scale is applied about the centre of the scene rather
* than about the model's own origin, because the room's meshes sit at a large
* offset inside the file and scaling from there would slide the whole room
* sideways instead of resizing it around the viewer. And it is written straight
* to the object with the world matrix refreshed on the spot: going through
* React state would land a frame later, after the pipeline has already
* re-captured the previous transforms, and every wall would register as one
* huge motion vector.
*/
export const Room = () => {
const groupRef = useRef<THREE.Group>(null);
const { nodes, materials } = useGLTF(MODEL_URL) as RoomGLTF;
useEffect(() => {
if (groupRef.current !== null) {
groupRef.current.visible = shot.current === "room";
}
return shot.subscribe((current) => {
const group = groupRef.current;
if (group === null) return;
// The red and grey shots are limbos: the room is not dressed
// differently in them, it is simply not there.
group.visible = current === "room";
if (current !== "room") return;
// Deliberately narrow. The camera orbits the centre out to 2.8 units on a
// cut, so shrinking the room much further would start putting walls
// between it and the subject.
const scale = 0.9 + Math.random() * 0.4;
group.scale.setScalar(scale);
group.position.copy(SCENE_CENTRE).multiplyScalar(1 - scale);
group.updateMatrixWorld(true);
});
}, []);
return (
<group ref={groupRef}>
<group position={MODEL_OFFSET} dispose={null}>
{PARTS.map(([node, material]) => (
<mesh
key={node}
geometry={nodes[node].geometry}
material={materials[material]}
/>
))}
</group>
</group>
);
};
useGLTF.preload(MODEL_URL);
datamosh-fullproject/src/scene/shots.tsx
import { useEffect, useMemo, useRef } from "react";
import * as THREE from "three";
import { useFrame, useThree } from "@react-three/fiber";
import { useEnvironment, useFBX } from "@react-three/drei";
import { SkeletonUtils, mergeVertices } from "three-stdlib";
import { sceneCut } from "@/state/scene-cut";
import { shot, Shot } from "@/state/shot";
import { createCheckerTexture } from "./checker-texture";
/**
* The sky, used both as the room's backdrop and as the scene's lighting.
*
* 1k rather than 2k: 1.1 MB instead of 4.4, and nothing in this demo can tell
* the difference. The lighting comes off a prefiltered version that throws away
* far more detail than the difference between the two resolutions anyway, and
* the background is seen through doorways on a picture rendered at 720p and
* then deliberately degraded.
*/
const HDRI = `${import.meta.env.BASE_URL}hdri/kloppenheim-puresky-1k.hdr`;
/**
* How far out of focus the sky is behind the room.
*
* Cheap lenses on small sensors do not resolve a sky, and a sharp horizon
* through a doorway is the one thing that reads as "render" in this shot. Costs
* nothing: it selects a coarser level of the prefiltered environment that has
* already been computed for the lighting.
*
* Two amounts, because the sky is doing two different jobs. Behind the room it
* is seen through doorways and is the only thing in those holes, so it is kept
* barely defocused: past about a fifth the cloud structure goes entirely and
* the openings become flat white panels, which is worse than a sharp sky for
* this effect - a smear needs edges to drag, and a constant value gives it
* none. Behind the dancers it is a backdrop and nothing else, with columns and
* seven figures in front of it supplying all the edges the mosh needs, so it
* can go properly soft.
*/
const SKY_BLUR = 0.12;
const LIMBO_SKY_BLUR = 0.45;
/**
* Backdrop and shot director.
*
* Two jobs, both of which have to be in the same place. It owns the sky texture,
* because the background is per shot and drei's `Environment` would insist on
* being the one to set it; and it is the *only* subscriber to `scene-cut`, from
* where it advances the shot. Everything else in the scene subscribes to the
* shot instead, which removes the question of which handler runs first.
*
* The sky stays as `scene.environment` in every shot even when it is not the
* background. It is what lights the dancer, and a figure lit by a sky reads as
* a figure; the same figure lit only by the point light reads as a cutout, and
* a cutout gives the mosh nothing to smear but its own outline.
*/
export const ShotStage = () => {
const scene = useThree((state) => state.scene);
const environment = useEnvironment({ files: HDRI });
useEffect(() => {
scene.environment = environment;
return () => {
scene.environment = null;
scene.backgroundBlurriness = 0;
};
}, [scene, environment]);
useEffect(() => {
// The sky is the backdrop of both shots now, defocused by different amounts
// rather than replaced by a flat colour in one of them. It costs nothing -
// the blur reads a coarser level of the environment map that has already
// been prefiltered for the lighting - and a soft sky behind the dancers
// still gives the effect a gradient to work on where a flat fill gave it a
// single value.
const apply = (current: Shot) => {
scene.background = environment;
scene.backgroundBlurriness =
current === "room" ? SKY_BLUR : LIMBO_SKY_BLUR;
};
apply(shot.current);
return shot.subscribe(apply);
}, [scene, environment]);
useEffect(() => sceneCut.subscribe(() => shot.advance()), []);
return null;
};
/**
* Renders its children only in the shots listed.
*
* Works on lights as well as on geometry: the renderer stops descending at an
* invisible group, so a light inside one is left out of the frame's light list
* entirely rather than merely contributing nothing.
*/
export const OnlyIn = ({
shots,
children,
}: {
shots: readonly Shot[];
children: React.ReactNode;
}) => {
const groupRef = useRef<THREE.Group>(null);
useEffect(() => {
const apply = (current: Shot) => {
const group = groupRef.current;
if (group !== null) group.visible = shots.includes(current);
};
apply(shot.current);
return shot.subscribe(apply);
}, [shots]);
return (
<group ref={groupRef} visible={shots.includes(shot.current)}>
{children}
</group>
);
};
/** Height, in world units, the model is normalised to. */
const DANCER_HEIGHT = 1.75;
/**
* Where the seven of them stand: x and z, the lead in front and three falling
* back on each side.
*
* A shallow V rather than a straight line, for two reasons. A straight rank
* seven wide does not fit a lens this close without pushing the camera so far
* back that the shot stops being about the figures; staggering them backwards
* lets perspective do the fitting. And it puts nearly two metres of depth in a
* shot that otherwise had almost none, which is what the effect reads to tell a
* near arm from a far one - the reason this shot uses a close lens in the first
* place.
*/
const DANCER_PLACES: readonly (readonly [number, number])[] = [
[0, 0],
[-1.1, -0.6],
[1.1, -0.6],
[-2.2, -1.2],
[2.2, -1.2],
[-3.3, -1.8],
[3.3, -1.8],
];
/**
* Columns standing well behind the line, and the only other thing in the shot.
*
* Not decoration. A flat backdrop gives the effect one silhouette to work on
* and nothing else: everything outside the figures is a single value, and a
* single value has no edges for the macroblocks to catch on, so the melt tears
* the dancers and leaves the rest of the frame perfectly clean. Vertical
* uprights at a fixed distance cut the red into bands, put hard edges where the
* dancers cross them, and give the depth buffer a third plane between the
* troupe and infinity.
*
* Spaced so they land between the figures rather than behind them, except for
* the middle one, which stands behind the lead.
*/
const COLUMN_X: readonly number[] = [-6.8, -3.4, 0, 3.4, 6.8];
const COLUMN_Z = -5.5;
const COLUMN_RADIUS = 0.35;
/**
* Long enough to leave the frame at both ends, and centred so it does.
*
* At this distance the shot sees roughly nine units of height, so a column that
* merely stood on y = 0 stopped in mid-air halfway down the picture and read as
* a cut-off prop. Running it past both edges is what makes it a column.
*/
const COLUMN_HEIGHT = 16;
const COLUMN_CENTRE_Y = 4;
/** Edge of the floor, and how many world units a single square covers. */
const FLOOR_SIZE = 500;
const FLOOR_SQUARE = 1.2;
/**
* The two tones of the checker.
*
* Held well inside the clipping point at the light end. The shot is lit by an
* outdoor sky with no tone mapping, and a floor that fills the bottom half of
* the frame is the last thing that can afford to blow out: once it clips it
* stops being a checker at all near the camera, where the squares are largest
* and the pattern is doing the most work.
*/
const FLOOR_LIGHT = "#b0aca4";
const FLOOR_DARK = "#3a3b40";
/**
* Strips the horizontal travel out of a clip, leaving it dancing on the spot.
*
* A capture exported with root motion carries the performer's real translation
* across the floor in the root bone's position track, and over a few seconds of
* choreography that is metres - enough to walk clean out of the shot. Only x
* and z are pinned, to the value they hold on the first keyframe: y is the
* vertical bob, and taking that out would leave the figure gliding.
*
* The clip is cloned rather than edited in place because `useFBX` hands out a
* cached object, and the clips hanging off it are shared with anything else
* that ever loads the same file.
*/
const pinInPlace = (clips: THREE.AnimationClip[]): THREE.AnimationClip[] =>
clips.map((source) => {
const clip = source.clone();
// Only the root carries travel; every other bone's position, if it is
// animated at all, is a local offset that has to be left alone.
const root =
clip.tracks.find((track) => /hips\.position$/i.test(track.name)) ??
clip.tracks.find((track) => track.name.endsWith(".position"));
if (root !== undefined) {
const values = root.values;
for (let i = 0; i < values.length; i += 3) {
values[i] = values[0];
values[i + 2] = values[2];
}
}
return clip;
});
/**
* The dancer: the whole of the middle shot, and nothing else.
*
* It belongs to the limbo shot alone. Standing it in the room as well would
* have made it the one thing the cut does *not* change, and a subject that
* survives the cut is a subject the effect cannot do anything interesting to -
* the melt lives on the two shots disagreeing, so the figure has to arrive with
* the cut and leave with it.
*
* The model arrives in whatever units the exporter used and with its origin
* wherever the rig's root happened to be, so it is measured on load and
* rescaled to a known height with its feet on y = 0, instead of relying on the
* usual 0.01 guess for a file authored in centimetres.
*/
export const Dancer = () => {
const groupRef = useRef<THREE.Group>(null);
const fbx = useFBX(`${import.meta.env.BASE_URL}models/thriller.fbx`);
const clips = useMemo(() => pinInPlace(fbx.animations), [fbx]);
// A near-black figure against a mid grey backdrop: the hardest luma step the
// frame can hold, which is exactly what the macroblocks tear along.
//
// `envMapIntensity` is the load-bearing setting. The scene is lit by an
// outdoor sky and rendered without tone mapping, so its irradiance runs
// several times over white - left at 1 it washes a 7% albedo up past 200 and
// there is no silhouette left at all.
//
// The sky is now the *only* thing lighting this shot, the point light having
// been taken out of it: a point light falls off with distance, so it lit the
// lead dancer visibly harder than the ones at the ends of the line. An
// environment is infinitely far away and lights all seven identically, which
// is the whole reason the shot is left to it.
const material = useMemo(
() =>
new THREE.MeshStandardMaterial({
color: "#1c1c22",
roughness: 0.6,
metalness: 0,
envMapIntensity: 0.9,
}),
[],
);
// Pale enough to separate from the near-black figures in front of them, dark
// enough not to clip: at this albedo the lit side lands around 0.7 of full
// scale, so the columns keep three distinguishable faces instead of becoming
// one white shape.
const columnMaterial = useMemo(
() =>
new THREE.MeshStandardMaterial({
color: "#b8ada0",
roughness: 0.8,
metalness: 0,
envMapIntensity: 0.25,
}),
[],
);
const columnGeometry = useMemo(
// Twelve sided rather than smooth: flat facets give the macroblocks a set
// of constant-value patches to lift, which is what makes them legible.
() =>
new THREE.CylinderGeometry(
COLUMN_RADIUS,
COLUMN_RADIUS,
COLUMN_HEIGHT,
12,
),
[],
);
const maxAnisotropy = useThree(
(state) => state.gl.capabilities.getMaxAnisotropy(),
);
const floor = useMemo(() => {
const map = createCheckerTexture({
light: FLOOR_LIGHT,
dark: FLOOR_DARK,
repeat: FLOOR_SIZE / (FLOOR_SQUARE * 2),
anisotropy: Math.min(8, maxAnisotropy),
});
return {
map,
geometry: new THREE.PlaneGeometry(FLOOR_SIZE, FLOOR_SIZE),
material: new THREE.MeshStandardMaterial({
map,
roughness: 0.9,
metalness: 0,
envMapIntensity: 0.25,
}),
};
}, [maxAnisotropy]);
useEffect(() => {
return () => {
columnGeometry.dispose();
columnMaterial.dispose();
floor.geometry.dispose();
floor.material.dispose();
floor.map.dispose();
};
}, [columnGeometry, columnMaterial, floor]);
const model = useMemo(() => {
// `useFBX` caches the parsed object and hands out the same instance, so
// this runs against something a previous mount may already have resized -
// and in StrictMode it runs twice on the first mount alone. Measuring an
// already normalised model yields a scale of 1, which is how a figure ends
// up back at its authored size of about 170 units with the camera inside
// it. Resetting first makes the whole block idempotent.
fbx.scale.setScalar(1);
fbx.position.set(0, 0, 0);
fbx.updateMatrixWorld(true);
const bounds = new THREE.Box3().setFromObject(fbx);
const size = bounds.getSize(new THREE.Vector3());
const scale = size.y > 1e-4 ? DANCER_HEIGHT / size.y : 1;
fbx.scale.setScalar(scale);
fbx.position.set(
-((bounds.min.x + bounds.max.x) / 2) * scale,
-bounds.min.y * scale,
-((bounds.min.z + bounds.max.z) / 2) * scale,
);
fbx.traverse((object) => {
const mesh = object as THREE.Mesh;
if (!mesh.isMesh) return;
mesh.material = material;
// A rig whose bounding sphere was computed in bind pose culls itself the
// moment a limb swings outside it.
mesh.frustumCulled = false;
// The exporter wrote every triangle out with three vertices of its own,
// so the rig arrives with no index buffer and each vertex is skinned
// three times over - 84,816 vertices for 28,272 triangles on the body
// mesh alone, and every one of those invocations is sixteen fetches into
// the bone texture. Across seven dancers that is a million vertex shader
// runs per pass, doubled while the velocity pass is measuring.
//
// The uv attribute goes first because nothing samples it: the material
// below carries no map of any kind, so `USE_UV` is never defined and the
// vertex shader does not even declare it. Dropping it also stops it
// acting as a discriminator in the weld.
//
// `mergeVertices` at 1e-6 joins only vertices that are bit-identical in
// every remaining attribute, so the triangles it emits are the same
// triangles in the same order - it just stops shading each of them three
// times. `useFBX` hands out a cached object, hence the index guard: this
// has to be idempotent across remounts and StrictMode's double call.
if (mesh.geometry.index === null) {
mesh.geometry.deleteAttribute("uv");
const indexed = mergeVertices(mesh.geometry, 1e-6);
mesh.geometry.dispose();
mesh.geometry = indexed;
}
});
return fbx;
}, [fbx, material]);
// No disposal of the material on unmount, deliberately. It is attached to the
// meshes of an object that `useFBX` keeps in a module-level cache for the
// lifetime of the page, so it outlives this component by design; releasing it
// here would free the GPU program of a model that is still going to be drawn
// the moment the component comes back - and in StrictMode that happens on the
// very first mount.
/**
* One rig per place in the line, each with its own mixer.
*
* The clones have to come from `SkeletonUtils`: a plain `Object3D.clone` copies
* the meshes but leaves every one of them bound to the *original* skeleton, so
* seven dancers would move as one. And there has to be a mixer each, because a
* clip addresses its tracks by bone name and a single mixer over all seven
* would resolve every track to whichever copy it found first.
*
* The materials are shared by reference, which is what `SkeletonUtils` does
* anyway: seven identical figures is the point, and one material means one
* shader program.
*/
const troupe = useMemo(() => {
const clip = clips[0];
return DANCER_PLACES.map((place) => {
const rig = SkeletonUtils.clone(model);
const mixer = new THREE.AnimationMixer(rig);
if (clip !== undefined) {
mixer
.clipAction(clip)
// Ping-pong, not repeat. The clip is a segment lifted out of a longer
// routine, so its last pose has nothing to do with its first and
// restarting from the top jumps. Playing it back the other way makes
// the turn exact by construction: the frame before the reversal and
// the frame after it are the same pose, so there is no seam to hide.
//
// It costs a sign flip in the motion field at each end, which is a
// discontinuity in velocity but not in position - the only thing the
// velocity pass measures - so the effect sees a change of direction
// rather than a teleport.
.setLoop(THREE.LoopPingPong, Infinity)
.play();
}
return { rig, mixer, place };
});
}, [model, clips]);
useEffect(() => {
return () => {
for (const dancer of troupe) {
dancer.mixer.stopAllAction();
dancer.mixer.uncacheRoot(dancer.rig);
}
};
}, [troupe]);
useEffect(() => {
const apply = (current: Shot) => {
const group = groupRef.current;
if (group !== null) group.visible = current === "dancer";
};
apply(shot.current);
return shot.subscribe(apply);
}, []);
useFrame((_, delta) => {
// Nothing to advance while the shot is off screen, and freezing the rigs
// there is harmless: the velocity pass re-records the bone matrices every
// frame regardless, so the poses it compares are still consecutive when the
// cut brings the shot back.
if (groupRef.current?.visible !== true) return;
for (const dancer of troupe) {
dancer.mixer.update(delta);
}
});
return (
<group ref={groupRef} visible={shot.current === "dancer"}>
{/*
The floor the whole shot stands on. It runs to the camera's far plane
so its edge falls on the horizon rather than showing as a line of sky
across the middle of the frame, and it hides the length of column that
continues below y = 0.
*/}
<mesh
geometry={floor.geometry}
material={floor.material}
rotation={[-Math.PI / 2, 0, 0]}
/>
{COLUMN_X.map((x) => (
<mesh
key={x}
geometry={columnGeometry}
material={columnMaterial}
position={[x, COLUMN_CENTRE_Y, COLUMN_Z]}
/>
))}
{troupe.map(({ rig, place }, index) => (
<group key={index} position={[place[0], 0, place[1]]}>
<primitive object={rig} />
</group>
))}
</group>
);
};
datamosh-fullproject/src/state/mosh-input.ts
/**
* Shared input state for the data mosh effect.
*
* Replaces the previous approach of synthesising fake KeyboardEvents to make
* mouse/touch input reach the post-processing component: every input source
* (spacebar, pointer, touch) now writes to this single store, and consumers
* subscribe to it.
*
* The state is **reference counted per source**. A single boolean was not
* enough: with three independent sources sharing one flag, a `mouseup` would
* end a gesture that the spacebar was still holding (and the other way round),
* so a stray click while a key was down silently cut the gesture short. Each
* source now holds its own token and the gesture ends when the last one is
* handed back.
*/
export type MoshInputSource = "keyboard" | "pointer" | "touch";
/**
* The latch is a source too, but not one of the above, because it is the one
* that must survive `releaseAll`.
*
* Everything in `MoshInputSource` is a physical hold whose matching release can
* go missing when the window loses focus, which is what `releaseAll` exists to
* recover from. A latch is a stated intention: the user ticked a box, nothing
* about switching tabs revokes it, and dropping it there would leave the box
* ticked with the effect off.
*/
let latched = false;
export interface MoshInputState {
/** True while at least one input source is holding the mosh trigger down. */
readonly pressed: boolean;
/** performance.now() of the last release, or -1 while pressed / before the first release. */
readonly lastReleaseTime: number;
}
const held = new Set<MoshInputSource>();
let snapshot: MoshInputState = { pressed: false, lastReleaseTime: -1 };
const listeners = new Set<() => void>();
const sync = () => {
const pressed = latched || held.size > 0;
if (pressed === snapshot.pressed) return;
snapshot = {
pressed,
// Only the transition to "nobody is holding it" starts the recovery clock.
lastReleaseTime: pressed ? -1 : performance.now(),
};
for (const listener of listeners) listener();
};
export const moshInput = {
press(source: MoshInputSource): void {
held.add(source);
sync();
},
release(source: MoshInputSource): void {
held.delete(source);
sync();
},
/**
* Drops every source at once. Used when the window loses focus or the tab is
* hidden: the keyup / pointerup of a held input is never delivered in that
* case, which would otherwise leave the trigger stuck down forever.
*/
releaseAll(): void {
if (held.size === 0) return;
held.clear();
sync();
},
/**
* Holds the trigger down until it is told otherwise.
*
* Deliberately not one of the reference-counted sources: a held key ends when
* the key comes up or the window loses focus, whereas this ends only when it
* is switched off. It is also *or*-ed with them rather than replacing them, so
* letting go of the spacebar while the latch is on does not end the gesture -
* and the gesture the latch starts is a real one, keyframe capture and scene
* cut included, because everything downstream reads the same `pressed` flag.
*/
setLatched(value: boolean): void {
if (latched === value) return;
latched = value;
sync();
},
subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
getSnapshot(): MoshInputState {
return snapshot;
},
};
datamosh-fullproject/src/state/scene-cut.ts
/**
* Registration point for the scene cut that a melt gesture triggers.
*
* The classic I-frame removal melt is not "an image smearing": it is the motion
* of shot B applied to the pixels of shot A. Without a cut there is only one
* shot, the motion vectors belong to the very picture they are dragging, and
* the result is a smear of the scene over itself - recognisably wrong, but not
* a datamosh.
*
* A 3D scene has no edit list to cut on, so the cut has to be *made*. It is a
* cut in the editing sense, not just a camera move: several parts of the scene
* subscribe and change at the same instant - the camera swings to another side
* of the room, the subject becomes a different shape, the room itself changes
* scale. The more the two shots disagree, the more there is for the motion
* vectors of the second to drag out of the first.
*
* Ownership is split deliberately. The pipeline knows *when* to cut (it is the
* only place that sees the gesture, the keyframe capture and the velocity state
* in the right order) but has no business deciding what may change in someone
* else's scene. The scene knows *what*, and subscribes here. The same
* indirection as `mosh-input`, in the opposite direction.
*
* Everything a subscriber changes must be applied immediately and have its
* world matrix refreshed before it returns: the pipeline re-captures the
* previous-frame transforms as soon as the cut is done, and anything still
* pending at that point would be measured as one enormous movement on the next
* frame instead of being invisible, which is the whole point of cutting there.
*/
export type SceneCutHandler = () => void;
const handlers = new Set<SceneCutHandler>();
export const sceneCut = {
/**
* Adds a handler and returns the function that removes it again, so a React
* effect can hand it straight back as its cleanup.
*/
subscribe(handler: SceneCutHandler): () => void {
handlers.add(handler);
return () => {
handlers.delete(handler);
};
},
/**
* Performs the cut.
*
* Returns whether anything was subscribed, because the caller has to
* re-capture the scene transforms afterwards and only needs to pay for that
* when something has actually moved.
*/
run(): boolean {
if (handlers.size === 0) {
return false;
}
// Copied first: a handler is free to unsubscribe itself (or another one)
// while the cut is being performed.
for (const handler of [...handlers]) {
handler();
}
return true;
},
};
datamosh-fullproject/src/state/shot.ts
/**
* Which of the two shots the edit is currently on.
*
* `scene-cut` says *that* a cut happens; this says *what we cut to*. The split
* exists because the cut is triggered from inside the pipeline, which has no
* business knowing there is such a thing as a room or a dancer, while the shot
* list is pure scene direction.
*
* The reason a shot list makes the effect better, and not just prettier: an
* I-frame removal melt is the motion of shot B applied to the pixels of shot A,
* so the two shots disagreeing is the whole material the effect works with.
* Swinging the camera to another corner of the same room is a weak
* disagreement - same walls, same palette, same lighting. Cutting to a white
* limbo, then to a black one, is about as far as two consecutive frames of a
* recording can get from each other: every macroblock the decoder holds is
* wrong in a different way.
*
* Everything a subscriber changes has to be applied immediately, for the reason
* spelled out in `scene-cut`: the pipeline re-captures the previous-frame
* transforms the instant the cut returns. Visibility flags and direct writes to
* `Object3D`, never React state.
*/
import * as THREE from "three";
export type Shot = "room" | "dancer";
/** The rotation, in order. A cut advances by one and wraps back to the room. */
export const SHOTS: readonly Shot[] = ["room", "dancer"];
/**
* Centre of the room: what the camera orbits, what the subject sits on, and the
* point the room is scaled about on a cut.
*/
export const SCENE_CENTRE = new THREE.Vector3(0, 1.5, 0);
export type ShotHandler = (current: Shot, previous: Shot) => void;
/**
* Where the camera stands for the shots that do not use the room's orbit.
*
* `fov` is only set where the shot wants a specific one; the others get the
* camera's own default back.
*
* The dancer's shot gets close instead of zooming, and that is a requirement of
* the effect rather than a preference. A long lens from far away compresses the
* whole figure into a thin slice of the depth range, so the depth buffer holds
* almost the same value everywhere on it and there is no parallax to drive: the
* subject and the emptiness behind it move together. Standing two metres away
* with a normal lens spreads the body across a real depth interval, and a hand
* reaching towards the camera then moves across the frame several times faster
* than the shoulder behind it.
*/
export const SHOT_CAMERA: Record<
Exclude<Shot, "room">,
{
position: readonly [number, number, number];
target: readonly [number, number, number];
fov?: number;
}
> = {
// Locked off. Every vector in this shot therefore belongs to the
// choreography, which is what the velocity pass reads out of the skeleton -
// see the previous-bone-texture half of `velocity-pass`, without which a
// still camera in front of a dancing rig produces an entirely empty motion
// field and the cut lands on a picture that does not move.
dancer: {
// Far enough back to hold the whole line of seven, close enough that the
// metre and a half of depth between the lead and the back pair reads as
// depth rather than as scale.
position: [0, 0.95, 3.4],
target: [0, 0.95, 0],
fov: 55,
},
};
let index = 0;
const handlers = new Set<ShotHandler>();
export const shot = {
/** The shot currently on screen. */
get current(): Shot {
return SHOTS[index];
},
/**
* Adds a handler and returns the function that removes it again, so a React
* effect can hand it straight back as its cleanup.
*/
subscribe(handler: ShotHandler): () => void {
handlers.add(handler);
return () => {
handlers.delete(handler);
};
},
/**
* Moves to the next shot and tells everyone, synchronously.
*
* Called from a single `scene-cut` subscriber (see `ShotStage`) rather than
* having every interested component subscribe to the cut itself: that way the
* advance provably happens before any handler reads `shot.current`, instead
* of depending on the order a Set happens to iterate in.
*/
advance(): void {
const previous = SHOTS[index];
index = (index + 1) % SHOTS.length;
const current = SHOTS[index];
// Copied first: a handler is free to unsubscribe itself while running.
for (const handler of [...handlers]) {
handler(current, previous);
}
},
};
datamosh-fullproject/vite.config.ts
import { fileURLToPath, URL } from 'node:url';
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
export default defineConfig({
// Relative asset URLs. Codrops deploys demos into a dedicated directory on
// their server rather than at a host root, so every emitted URL - the bundle,
// the icons, and `import.meta.env.BASE_URL` in the source - has to resolve
// against the document instead of against `/`.
base: './',
plugins: [react()],
resolve: {
// The `@/...` imports the source uses throughout, resolved the same way the
// TypeScript `paths` entry in `tsconfig.json` resolves them.
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
build: {
// three.js alone is past Rollup's default 500 kB warning threshold, and the
// whole point of this page is to ship a WebGL scene, so the warning carries
// no information here.
chunkSizeWarningLimit: 1500,
},
});
Google Draco decoder notices실행 안내·자료
Google Draco 1.5.5 license and bundled notices
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
--------------------------------------------------------------------------------
Files: docs/assets/js/ASCIIMathML.js
Copyright (c) 2014 Peter Jipsen and other ASCIIMathML.js contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
--------------------------------------------------------------------------------
Files: docs/assets/css/pygments/*
This is free and unencumbered software released into the public domain.
Anyone is free to copy, modify, publish, use, compile, sell, or
distribute this software, either in source code form or as a compiled
binary, for any purpose, commercial or non-commercial, and by any
means.
In jurisdictions that recognize copyright laws, the author or authors
of this software dedicate any and all copyright interest in the
software to the public domain. We make this dedication for the benefit
of the public at large and to the detriment of our heirs and
successors. We intend this dedication to be an overt act of
relinquishment in perpetuity of all present and future rights to this
software under copyright law.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
For more information, please refer to <http://unlicense.org>
Media credits and license evidence실행 안내·자료
README.md
## Credits
- Sky: `kloppenheim_puresky` from
[Poly Haven](https://polyhaven.com/license), CC0.
- Dancer model and animation: `thriller.fbx` from
[Adobe Mixamo](https://helpx.adobe.com/creative-cloud/faq/mixamo-faq.html).
Adobe permits royalty-free use in projects; the asset remains governed by
[Adobe's terms](https://www.adobe.com/legal/terms.html) and is not relicensed
under this repository's MIT licence.
- Room: `backroom-transformed.glb`. The binary contains no upstream author or
licence metadata; confirm its provenance before making the repository public.
## Licence
The [MIT licence](LICENSE) applies to the source code. Third-party assets
listed above are excluded.
LICENSE실행 안내·자료
MIT License
Copyright (c) 2026 Niccolò Fanton
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Bundled dependency licenses실행 안내·자료
@niccolofanton/driftpane@1.4.0 — LICENSE.txt
Copyright (c) 2016 cocopon <cocopon@me.com>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
three@0.173.0 — LICENSE
The MIT License
Copyright © 2010-2025 three.js authors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
postprocessing@6.39.5 — LICENSE.md
Copyright © 2015 Raoul van Rüschen
This software is provided 'as-is', without any express or implied warranty. In
no event will the authors be held liable for any damages arising from the use of
this software.
Permission is granted to anyone to use this software for any purpose, including
commercial applications, and to alter it and redistribute it freely, subject to
the following restrictions:
1. The origin of this software must not be misrepresented; you must not claim
that you wrote the original software. If you use this software in a product,
an acknowledgment in the product documentation would be appreciated but is
not required.
2. Altered source versions must be plainly marked as such, and must not be
misrepresented as being the original software.
3. This notice may not be removed or altered from any source distribution.
react@18.3.1 — LICENSE
MIT License
Copyright (c) Facebook, Inc. and its affiliates.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
scheduler@0.23.2 — LICENSE
MIT License
Copyright (c) Facebook, Inc. and its affiliates.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
react-dom@18.3.1 — LICENSE
MIT License
Copyright (c) Facebook, Inc. and its affiliates.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@babel/runtime@7.29.7 — LICENSE
MIT License
Copyright (c) 2014-present Sebastian McKenzie and other contributors
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
react-reconciler@0.27.0 — LICENSE
MIT License
Copyright (c) Facebook, Inc. and its affiliates.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
zustand@3.7.2 — LICENSE
MIT License
Copyright (c) 2019 Paul Henschel
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
suspend-react@0.1.3 — LICENSE
MIT License
Copyright (c) 2021 Paul Henschel
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
scheduler@0.21.0 — LICENSE
MIT License
Copyright (c) Facebook, Inc. and its affiliates.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
react-use-measure@2.1.7 — LICENSE
MIT License
Copyright (c) 2019-2025 Poimandres
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
its-fine@1.2.5 — LICENSE
MIT License
Copyright (c) 2022 Poimandres
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@react-three/drei@9.122.0 — LICENSE
MIT License
Copyright (c) 2020 react-spring
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
zustand@5.0.15 — LICENSE
MIT License
Copyright (c) 2019 Paul Henschel
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@use-gesture/core@10.3.1 — LICENSE
Copyright (c) 2018-present Paul Henschel <drcmda@gmail.com>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@use-gesture/react@10.3.1 — LICENSE
Copyright (c) 2018-present Paul Henschel <drcmda@gmail.com>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@react-spring/rafz@9.7.5 — LICENSE
MIT License
Copyright (c) 2018-present Paul Henschel, react-spring, all contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@react-spring/shared@9.7.5 — LICENSE
MIT License
Copyright (c) 2018-present Paul Henschel, react-spring, all contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@react-spring/animated@9.7.5 — LICENSE
MIT License
Copyright (c) 2018-present Paul Henschel, react-spring, all contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@react-spring/types@9.7.5 — LICENSE
MIT License
Copyright (c) 2018-present Paul Henschel, react-spring, all contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@react-spring/core@9.7.5 — LICENSE
MIT License
Copyright (c) 2018-present Paul Henschel, react-spring, all contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@react-spring/three@9.7.5 — LICENSE
MIT License
Copyright (c) 2018-present Paul Henschel, react-spring, all contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
three-stdlib@2.36.1 — LICENSE
MIT License
Copyright (c) 2021-2023 Poimandres
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
potpack@1.0.2 — LICENSE
ISC License
Copyright (c) 2018, Mapbox
Permission to use, copy, modify, and/or distribute this software for any purpose
with or without fee is hereby granted, provided that the above copyright notice
and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS
OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER
TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
THIS SOFTWARE.
fflate@0.6.11 — LICENSE
MIT License
Copyright (c) 2020 Arjun Barrett
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
troika-worker-utils@0.52.0 — LICENSE
MIT License
Copyright (c) 2019 ProtectWise
Copyright (c) 2021 Jason Johnston
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
webgl-sdf-generator@1.1.1 — LICENSE.txt
Copyright (c) 2021 Jason Johnston
MIT License
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
bidi-js@1.1.0 — LICENSE.txt
Copyright (c) 2021 Jason Johnston
MIT License
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
troika-three-utils@0.52.5 — LICENSE
MIT License
Copyright (c) 2019 ProtectWise
Copyright (c) 2021 Jason Johnston
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
troika-three-text@0.52.5 — LICENSE
MIT License
Copyright (c) 2019 ProtectWise
Copyright (c) 2021 Jason Johnston
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
meshline@3.3.1 — LICENSE
MIT License
Copyright (c) 2016 Jaume Sanchez
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
camera-controls@2.10.1 — LICENSE
MIT License
Copyright (c) 2017 @yomotsu
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
hls.js@1.7.3 — LICENSE
Copyright (c) 2017 Dailymotion (http://www.dailymotion.com)
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
src/remux/mp4-generator.js and src/demux/exp-golomb.ts implementation in this project
are derived from the HLS library for video.js (https://github.com/videojs/videojs-contrib-hls)
That work is also covered by the Apache 2 License, following copyright:
Copyright (c) 2013-2015 Brightcove
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
stats.js@0.17.0 — LICENSE
The MIT License
Copyright (c) 2009-2016 stats.js authors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
detect-gpu@5.0.70 — LICENSE
MIT License
Copyright (c) 2020 Tim van Scherpenzeel
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
three-mesh-bvh@0.7.8 — LICENSE
MIT License
Copyright (c) 2018 Garrett Johnson
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
prop-types@15.8.1 — LICENSE
MIT License
Copyright (c) 2013-present, Facebook, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
react-composer@5.0.3 — LICENSE
MIT License
Copyright (c) 2018 James, please
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@monogrid/gainmap-js@3.4.0 — LICENSE
MIT License
Copyright (c) 2023 MONOGRID
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
zustand@4.5.7 — LICENSE
MIT License
Copyright (c) 2019 Paul Henschel
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
use-sync-external-store@1.7.0 — LICENSE
MIT License
Copyright (c) Meta Platforms, Inc. and affiliates.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
tunnel-rat@0.1.2 — LICENSE
MIT License
Copyright (c) 2022 Poimandres
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
zustand@4.5.7 — LICENSE
MIT License
Copyright (c) 2019 Paul Henschel
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
r3f-perf@7.2.3 — LICENSE
MIT License
Copyright (c) 2021-2023 Renaud ROHLINGER
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
eventemitter3@4.0.7 — LICENSE
The MIT License (MIT)
Copyright (c) 2014 Arnout Kazemier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@radix-ui/react-icons@1.3.2 — LICENSE
MIT License
Copyright (c) 2022 WorkOS
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
BEHIND THE EXAMPLE
이 장면을 만드는 원리
현재 카메라와 이전 프레임 행렬의 구분
화면 크기·입력·동시 작업을 고정하고 실제 렌더링 실패와 비용을 관찰합니다.
- 이 예제에서는
- DataMoshEffect는 이전 camera matrixWorld와 projectionMatrix를 별도로 저장합니다. 이전 기록이 없을 때는 현재 카메라를 사용하고 회전 전용 재투영에는 현재 위치를 복사합니다.
