Director’s commentary · The Carpet Sea
Turning the carpet into the sea
The engine machinery behind Disk 3's big moment: looks built from uniforms, tweened rather than rebuilt, so a cosy living room can become the Caribbean without a cut.
The Carpet Sea is the transformation the whole game is built around. My sister and I are deep in Pirates!, the game spills out into the living room, and as a sword fight with my brother and sister heats up, the room turns into the Caribbean: the carpet into water, the sofa into an island, the coffee table into a galleon. The chapter itself is still being built, and its water shader with it. What is finished is the machinery underneath, the part that lets one look become another without a cut, a rebuild or a dropped frame. That's what this page is about.
Every look is a set of uniforms
A look is the whole feel of a chapter: which post-processing steps run, the colour grade, bloom, grain, fog and background. Early on I made one rule that everything else depends on. Every numeric parameter of a look is a shader uniform, and every effect is invisible at its default value. Contrast 1, saturation 1, grain 0, vignette 0, fade 0. A look only lists what it changes.
/** Neutral values: every effect is invisible at its default, so looks tween cleanly between post stacks. */
export const DEFAULT_LOOK_PARAMS: LookParams = {
exposure: 1,
contrast: 1,
saturation: 1,
temperature: 0,
tint: [1, 1, 1],
vignette: 0,
grain: 0,
bloomStrength: 0,
bloomThreshold: 0.8,
bloomRadius: 0.4,
dofFocus: 5,
dofAperture: 0.02,
dofMaxBlur: 0,
crtCurvature: 0,
scanlines: 0,
posterize: 0,
paper: 0,
flicker: 0,
outline: 0,
chromatic: 0,
fadeColor: [0, 0, 0],
fade: 0,
};Because the parameters are uniforms, changing one is just writing a number into a buffer. The TSL graph that makes up the post-processing chain is only rebuilt when the set of steps changes, never when a value does. So a tween is cheap: every frame it writes a handful of numbers, and the GPU does the rest.
export function createUniforms(): LookUniforms {
const u: Record<string, unknown> = {};
for (const [k, v] of Object.entries(DEFAULT_LOOK_PARAMS)) {
u[k] = Array.isArray(v) ? uniform(new THREE.Vector3(...v)) : uniform(v);
}
return {
...(u as ParamUniforms),
time: uniform(0),
motion: uniform(1),
pixelRatio: uniform(1),
near: uniform(0.1),
far: uniform(100),
};
}
export function writeUniforms(u: LookUniforms, p: LookParams) {
for (const k of Object.keys(p) as (keyof LookParams)[]) {
const v = p[k];
if (Array.isArray(v)) (u[k] as V3).value.set(v[0], v[1], v[2]);
else (u[k] as F).value = v;
}
}Two looks, one graph
The living room and the sea are two separate looks, and they were written to share exactly the same four steps: grade, bloom, vignette and grain. That was deliberate. Only the numbers differ: the room is warm, soft and slightly desaturated, and the sea is saturated, cooler, brighter and blooming.
"carpet-sea": look("carpet-sea", {
// A cosy living room, before the carpet turns to water
post: ["grade", "bloom", "vignette", "grain"],
params: {
exposure: 1.04, contrast: 0.9, saturation: 0.95, temperature: 0.4, tint: [1, 0.95, 0.9],
bloomStrength: 0.45, bloomThreshold: 0.7, bloomRadius: 0.6,
vignette: 0.25, grain: 0.1,
},
uiSkin: "parchment",
background: "#5e4233",
fog: { color: "#5e4233", near: 8, far: 20 },
music: "shanty",
ambience: "living-room",
}),
"carpet-sea-sea": look("carpet-sea-sea", {
// Caribbean blue and gold
post: ["grade", "bloom", "vignette", "grain"],
params: {
exposure: 1.08, contrast: 1.1, saturation: 1.2, temperature: 0.05, tint: [0.96, 1.02, 1.04],
bloomStrength: 0.8, bloomThreshold: 0.62, bloomRadius: 0.6,
vignette: 0.25, grain: 0.05,
},
uiSkin: "parchment",
background: "#4fc0d9",
fog: { color: "#9fe2ea", near: 9, far: 32 },
music: "shanty",
ambience: "sea",
}),In Ink, the switch is a single tag, # look: carpet-sea-sea, and the runtime turns that into a 1.2-second change. If the two looks share their steps, it tweens every parameter at once, including the ones the target leaves at their defaults, so nothing gets stuck halfway. If they don't share steps, or the player has asked for reduced motion, it snaps.
async look(id: string, opts?: { ms?: number }): Promise<void> {
const cfg = id === "base" ? this.pack.look : this.pack.looks?.[id] ?? LOOKS[id];
if (!cfg) {
console.warn(`[look] unknown look "${id}"`);
return;
}
const pipeline = this.world.pipeline;
const sameSteps = cfg.post.join() === this.currentLook.post.join();
if (pipeline && opts?.ms && sameSteps && !this.reducedMotion) {
this.applyLook(cfg, false);
// Tween every parameter, so ones the target look leaves at default (e.g. fade) return to it.
await pipeline.tween({ ...DEFAULT_LOOK_PARAMS, ...cfg.params }, opts.ms);
} else {
this.applyLook(cfg, true);
}
}Tweening without surprises
The tweener eases every value with a smoothstep curve, and a new tween on a parameter quietly takes that parameter over from any tween already running, so a script can change its mind halfway through without two tweens fighting. Most values interpolate in a straight line. Posterising doesn't, because it's measured in colour levels, and going straight from 0 (off) to 6 would pass through 1 and 2 levels: a flash of black and white in the middle of a nice cross-fade. So it tweens in one over the level count, and passes through 60, 30 and 15 levels instead.
/** Posterize tweens in 1/levels so 0 (off) → 6 passes through 60, 30, 15… levels instead of 1 and 2. */
function lerpPosterize(a: number, b: number, t: number) {
const inv = lerp(a > 0 ? 1 / a : 0, b > 0 ? 1 / b : 0, t);
return inv > 0 ? 1 / inv : 0;
}For changes between looks that don't share steps, the pipeline has a transition that builds the union of both step sets (safe, because every effect is invisible at its neutral value), tweens the parameters, the background and the fog together, and then settles on the new look's own steps.
async transition(look: LookConfig, ms: number): Promise<void> {
if (ms <= 0) return this.apply(look);
const id = ++this.transitionId;
this.post = [...new Set([...this.post, ...look.post])];
this.rebuild();
const target = look.fog ?? { color: look.background, ...NO_FOG };
const from = this.scene.fog === this.fog ? this.fog : { color: this.background, ...NO_FOG };
this.sceneTween = {
bg: [this.background.clone(), new THREE.Color(look.background)],
fog: [from.color.clone(), new THREE.Color(target.color)],
near: [from.near, target.near],
far: [from.far, target.far],
elapsed: 0,
ms,
};
this.scene.background = this.background;
this.scene.fog = this.fog;
await this.tween(lookParams(look), ms);
if (id === this.transitionId) this.apply(look);
}Already working: the prologue's last bite
The same mechanism is what ends the prologue today. When you take the last bite of the sandwich, the chapter tweens exposure and bloom up and fades the whole frame to warm white, over 2.6 seconds, or 300 milliseconds with reduced motion on. The memory whites out into sunlight, and the title card comes up out of it.
// The last bite whites the screen out into sunlight (spec §4 Prologue payoff).
api.sfx("chime");
await api.tweenLook({ exposure: 1.7, bloomStrength: 2.4, fade: 1, fadeColor: [1, 0.98, 0.93] }, api.reducedMotion ? 300 : 2600);
await api.wait(400);
await api.complete();The Carpet Sea's water, when it's done, will work on the same principle. The plan is a floor material with a single number for how far the sea has spread, tweened alongside the grade, so the carpet gives way to water outwards from wherever the fight is, while the light turns from lamp-lit brown to Caribbean blue and gold around it. The sofa and the coffee table becoming an island and a galleon are a separate job. None of that is built yet, so I won't pretend to show you code for it.
What I'd do differently
The runtime's look switch tweens the post-processing parameters but snaps the scene's background colour and fog to the new look straight away. In a closed room with walls you rarely see past, that's hard to spot. At the edges of a sea that stretches off into fog, it won't be. The pipeline's transition already tweens the background and fog properly; the runtime just doesn't route through it yet. Before the Carpet Sea ships, it will.