Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ The table below shows which shared modules each game and the Lab import. `script
| `gpu/{motion,motion-blur,sprite-motion}` | Depth-aware 8-tap shutter gather and actor/sprite velocity history | Yes | Yes | Yes | — | Yes | Yes | — |
| `gpu/{surface-materials,foliage}` & `textures` | Procedural surface recipes, 16-layer WebP array, foliage, and trample | — | Yes | Yes | — | Yes | Yes | — |
| `props/*` | Generated furniture library: catalogue, `.prop` meshes, instanced batches, seeded room placement | — | — | Yes | — | Yes | — | — |
| `look` & `gpu/look-shaders` | `LookSettings` presets and WGSL scene/post grading hooks | — | Yes | Yes | — | Yes | Yes | Yes |
| `look`, `gpu/look-shaders` & `gpu/post-uniform` | `LookSettings` presets, WGSL scene/post grading hooks and the game post uniform after them | — | Yes | Yes | — | Yes | Yes | Yes |
| `sim/creature-motion` | Lamplight's creature root: deterministic planar momentum, brake/plant/pivot phases, `SecondOrder` springs, Froude scaling | — | — | — | — | Yes | — | — |
| `creature/*` | Arachnid legs (world-space feet, footholds, swing clearance, IK, body springs, contacts), matte tube view and Lab settings | — | — | — | — | Yes | — | — |
| `gpu/post/camcorder` | Opt-in Camera filter final pass (iPhone, VHS, CRT TV, body cam) on `Renderer.setFinalPass` | — | — | Yes | — | — | — | — |
Expand Down
7 changes: 4 additions & 3 deletions packages/kit/architecture-map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,12 +214,13 @@ export const ARCHITECTURE_MAP_ROWS: readonly ArchitectureMapRow[] = [
match: (s) => s.startsWith("@arcade/kit/props/"),
},
{
area: "`look` & `gpu/look-shaders`",
description: "`LookSettings` presets and WGSL scene/post grading hooks",
area: "`look`, `gpu/look-shaders` & `gpu/post-uniform`",
description: "`LookSettings` presets, WGSL scene/post grading hooks and the game post uniform after them",
match: (s) =>
s === "@arcade/kit/look" ||
s === "@arcade/kit/gpu/look" ||
s === "@arcade/kit/gpu/look-shaders",
s === "@arcade/kit/gpu/look-shaders" ||
s === "@arcade/kit/gpu/post-uniform",
},
{
area: "`sim/creature-motion`",
Expand Down
13 changes: 8 additions & 5 deletions packages/kit/gpu/screen/light.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,18 +129,21 @@ export function screenSceneLight(display: ScreenEmitter, at: ScreenPlacement, op

/**
* For baked lighting: the live scale of a light's baked bounce. A lightmap baker that baked a screen's indirect light
* at `reference` (average emitted radiance, scene units) multiplies that light's baked contribution by `scale()` each
* frame, so the room's bounce follows the picture as its direct light does. `id` is the `SceneLight.id` it baked.
* in white at `reference` (average emitted radiance, scene units) multiplies that light's baked contribution by
* `scale()` and tints it by `color()` each frame, so the room's bounce follows the picture as its direct light does.
* Like `screenSceneLight`, the scale is the brightest channel and the colour is relative to it, so
* `scale() × color()` is the emission per channel: a blue picture throws its blue at full strength, not dimmed to its
* luminance. `id` is the `SceneLight.id` it baked.
*/
export type LiveBounceSource = { readonly id: number; scale(): number; color(): readonly [number, number, number] }

export function screenBounceSource(display: ScreenEmitter, id: number, reference = 1): LiveBounceSource {
const lum = (c: readonly number[]) => 0.2126 * c[0]! + 0.7152 * c[1]! + 0.0722 * c[2]!
const peak = (e: readonly number[]) => Math.max(e[0]!, e[1]!, e[2]!, 0)
return {
id,
scale: () => (lum(display.emission()) * display.settings.light) / Math.max(reference, 1e-6),
scale: () => (peak(display.emission()) * display.settings.light) / Math.max(reference, 1e-6),
color: () => {
const e = display.emission(), m = Math.max(e[0], e[1], e[2], 1e-6)
const e = display.emission(), m = Math.max(peak(e), 1e-6)
return [e[0] / m, e[1] / m, e[2] / m]
},
}
Expand Down
11 changes: 11 additions & 0 deletions packages/kit/gpu/screen/screen.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,17 @@ describe("screen light", () => {
expect(bounce.scale()).toBeCloseTo(2, 6)
})

test("a cool picture lights in its own colour at full strength, in direct light and baked bounce alike", () => {
// The sea show's average: dark navy to steel blue, its luminance a third of its blue.
const sea: [number, number, number] = [0.011, 0.145, 0.39]
const direct = screenSceneLight(emitter(sea), at)!
const bounce = screenBounceSource(emitter(sea), 4, 1)
for (let c = 0; c < 3; c++) {
expect(direct.color[c]! * direct.power * 5 / 0.12).toBeCloseTo(sea[c]!, 6)
expect(bounce.color()[c]! * bounce.scale()).toBeCloseTo(sea[c]!, 6)
}
})

test("half floats decode", () => {
expect(halfToFloat(0x3c00)).toBe(1)
expect(halfToFloat(0x3800)).toBe(0.5)
Expand Down
2 changes: 1 addition & 1 deletion packages/kit/lab/world/presets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export const WORLD_PRESETS: readonly WorldPreset[] = [
{ id: "night-lamps", label: "Night lamps", idea: "light", note: "Midnight, dry: streetlamp, porch lamp and the lamp inside only.", values: { hour: 0 } },
{ id: "night-physical", label: "Night lamps, physical", idea: "light", note: "Night lamps under the physical light model: inverse-square pools, the streetlamp's 3D cone, GGX highlights and sky reflections.", values: { hour: 0, lightModel: "physical" } },
{ id: "spot-shadows", label: "Spot shadow maps", idea: "light", note: "Night lamps, physical, with perspective shadow maps for the streetlamp and porch cones: the canopy, car and porch roof cast what plan shadows can't.", values: { hour: 0, lightModel: "physical", spotShadows: 2 } },
{ id: "screen-bench", label: "CRT in a dark room", idea: "light", note: "Midnight inside the house, lamps off: a CRT television on a workbench is the only light, and the room follows its picture.", values: { hour: 0, lightModel: "physical", lampPower: 0, skyLight: 0.015, exposureEV: 0.6, screen: true, "screen.light": 6 }, camera: SCREEN_CAMERA },
{ id: "screen-bench", label: "CRT in a dark room", idea: "light", note: "Midnight inside the house, lamps off: a CRT television on a workbench is the only light, and the room follows its picture.", values: { hour: 0, lightModel: "physical", lampPower: 0, skyLight: 0.015, exposureEV: 0.8, shadowLift: 0, screen: true, "screen.light": 6 }, camera: SCREEN_CAMERA },
{ id: "dying-lamps", label: "Dying lamps", idea: "light", note: "Midnight with the shared flicker: the sodium streetlamp cycles out and restrikes pink, the porch tube stutters.", values: { hour: 0, flicker: 1.5 } },
{ id: "brick-street", label: "Brick and concrete", idea: "materials", note: "Brick-red walls, dry asphalt, plaza paving, carpet inside.", values: { wall: "brick", street: 5, walk: 7, path: 7, floor: 0, car: "teal" } },
{ id: "autumn", label: "Autumn yard", idea: "vegetation", note: "Maple, an overgrown shrub and an overgrown lawn at 16:00.", values: { hour: 16, treePreset: "maple", bushPreset: "overgrown", grassPreset: "overgrown" } },
Expand Down
52 changes: 37 additions & 15 deletions packages/kit/lab/world/scene.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { buildFoliageBush, buildFoliageTree, emitGrassClump, scatterGrassClumps, type FoliageParams } from "../../gpu/foliage"
import type { LevelData } from "../../gpu/level"
import { MAT, Mesh, hex, type Rgb } from "../../gpu/mesh"
import { FLOATS_PER_VERT, MAT, Mesh, hex, type Rgb } from "../../gpu/mesh"
import { coldSodiumTint, type FlickerSample } from "../../gpu/lights/flicker"
import { cagedBulbMesh, fluorescentTubeMesh, ledIndicatorMesh, type CagedBulb, type FluorescentTube, type LedIndicator } from "../../gpu/lights/practicals"
import type { ScreenPlacement } from "../../gpu/screen/light"
Expand Down Expand Up @@ -273,11 +273,17 @@ const [OX, OZ] = WORLD_ORIGIN
export const SCREEN_AT: ScreenPlacement = { center: [TV.x - OX, TV.screenY, TV.front - 0.022 - OZ], yaw: Math.PI / 2, width: TV.screenW, height: TV.screenH }
/** The room the glass reflects (inside the house walls, world coordinates). */
export const SCREEN_ROOM = { min: [11.18 - OX, 0.24, 8.18 - OZ] as const, max: [18.64 - OX, 2.9, 13.64 - OZ] as const, albedo: 0.5 }
/** An armchair facing the set across a pale rug: what the screen's light falls on (plan coordinates). */
const CHAIR = { x0: 15.78, x1: 16.58, z0: 10.55, z1: 11.35, seat: 0.66 } as const
const RUG = { x0: 15.2, x1: 17.1, z0: 9.05, z1: 11.6 } as const
/** Where the screen lab looks from: inside the room, off to one side, the set, the rug and the chair in view. */
export const SCREEN_CAMERA = { target: [TV.x - OX - 0.3, 0.86, 9.85 - OZ] as const, yaw: 0.95, pitch: 0.24, dist: 2.6 } as const
/**
* An armchair on a pale rug, 1.6 m from the set and 46° off its axis, turned 30° from the set toward the camera: what
* the screen's light falls on (plan coordinates, `turn` in radians). `half` is half its width and depth.
*/
export const SCREEN_CHAIR = { x: 15, z: 9.95, half: 0.4, turn: Math.PI / 6, seat: 0.66 } as const
const RUG = { x0: 14.3, x1: 16.6, z0: 9.0, z1: 10.75 } as const
/**
* Where the screen lab looks from: the room's front corner at standing eye height, the set on the right third with its
* glass 30° off axis, the chair three-quarter on (its lit front 54° from the camera) on the left, the rug between.
*/
export const SCREEN_CAMERA = { target: [15.69 - OX, 0.9, 9.277 - OZ] as const, yaw: 0.663, pitch: 0.24, dist: 4.5 } as const
export const SCREEN_TUBE: FluorescentTube = { at: [16.45 - OX, 2.55, 8.62 - OZ], length: 1.2, seed: 29, failing: 1.2, power: 1.1 }
export const SCREEN_BULB: CagedBulb = { at: [18.42 - OX, 1.95, 9.55 - OZ], seed: 31, wander: 0.7, power: 0.55 }
export const SCREEN_LEDS: { tv: LedIndicator; deck: LedIndicator; deckActivity: LedIndicator } = {
Expand Down Expand Up @@ -319,15 +325,19 @@ function addScreenBench(m: Mesh, b: WorldBuild): void {
ledIndicatorMesh(m, shift(SCREEN_LEDS.tv), LAMP.tvLed)
ledIndicatorMesh(m, shift(SCREEN_LEDS.deck), LAMP.deckLed)
ledIndicatorMesh(m, shift(SCREEN_LEDS.deckActivity), LAMP.deckLed)
// A pale rug and an armchair facing the set: the seat, a high back, two arms and short feet.
m.box(RUG.x0, 0.24, RUG.z0, RUG.x1, 0.252, RUG.z1, rgb(0xa79c86), MAT.lit)
const fabric = rgb(0x8d7f69), fabricDark = rgb(0x6f6352)
const c = CHAIR
for (const fx of [c.x0 + 0.05, c.x1 - 0.09]) for (const fz of [c.z0 + 0.05, c.z1 - 0.09]) m.box(fx, 0.252, fz, fx + 0.04, 0.33, fz + 0.04, dark, MAT.lit)
m.bevelBox(c.x0, 0.33, c.z0, c.x1, c.seat - 0.12, c.z1, 0.02, fabricDark, fabric, MAT.lit)
m.bevelBox(c.x0 + 0.12, c.seat - 0.12, c.z0 + 0.04, c.x1 - 0.12, c.seat, c.z1 - 0.18, 0.03, fabric, fabric, MAT.lit)
m.bevelBox(c.x0 + 0.04, 0.33, c.z1 - 0.2, c.x1 - 0.04, 1.32, c.z1, 0.04, fabric, fabric, MAT.lit)
for (const ax of [c.x0, c.x1 - 0.12]) m.bevelBox(ax, 0.33, c.z0 + 0.02, ax + 0.12, 0.86, c.z1 - 0.06, 0.03, fabric, fabric, MAT.lit)
// A pale rug and an armchair near the set, both near-neutral so they take the picture's colour: the seat, a high back,
// two arms and short feet.
m.box(RUG.x0, 0.24, RUG.z0, RUG.x1, 0.252, RUG.z1, rgb(0xb3b0a9), MAT.lit)
const fabric = rgb(0x9a968e), fabricDark = rgb(0x77736b)
// Built round the origin facing −z, then turned to face the glass (less `turn`) and moved into place.
const c = SCREEN_CHAIR, cx0 = -c.half, cx1 = c.half, cz0 = -c.half, cz1 = c.half, from = m.length
for (const fx of [cx0 + 0.05, cx1 - 0.09]) for (const fz of [cz0 + 0.05, cz1 - 0.09]) m.box(fx, 0.252, fz, fx + 0.04, 0.33, fz + 0.04, dark, MAT.lit)
m.bevelBox(cx0, 0.33, cz0, cx1, c.seat - 0.12, cz1, 0.02, fabricDark, fabric, MAT.lit)
m.bevelBox(cx0 + 0.12, c.seat - 0.12, cz0 + 0.04, cx1 - 0.12, c.seat, cz1 - 0.18, 0.03, fabric, fabric, MAT.lit)
m.bevelBox(cx0 + 0.04, 0.33, cz1 - 0.2, cx1 - 0.04, 1.32, cz1, 0.04, fabric, fabric, MAT.lit)
for (const ax of [cx0, cx1 - 0.12]) m.bevelBox(ax, 0.33, cz0 + 0.02, ax + 0.12, 0.86, cz1 - 0.06, 0.03, fabric, fabric, MAT.lit)
const face = Math.atan2(TV.front - 0.022 - c.z, TV.x - c.x) + c.turn
turnFrom(m, from, Math.atan2(Math.cos(face), -Math.sin(face)), c.x, c.z)
// The practicals, shown only when they're on.
if (b.tube) {
const t = { ...SCREEN_TUBE, at: [SCREEN_TUBE.at[0] + OX, SCREEN_TUBE.at[1], SCREEN_TUBE.at[2] + OZ] as const }
Expand All @@ -341,6 +351,18 @@ function addScreenBench(m: Mesh, b: WorldBuild): void {
}
}

/** Turns every vertex added since `from` by `yaw` about the origin (as `Mesh.yawBox`), then moves it to (x, z). */
function turnFrom(m: Mesh, from: number, yaw: number, x: number, z: number): void {
const c = Math.cos(yaw), s = Math.sin(yaw)
for (let i = from; i < m.v.length; i += FLOATS_PER_VERT) {
const px = m.v[i]!, pz = m.v[i + 2]!, nx = m.v[i + 3]!, nz = m.v[i + 5]!
m.v[i] = px * c - pz * s + x
m.v[i + 2] = px * s + pz * c + z
m.v[i + 3] = nx * c - nz * s
m.v[i + 5] = nx * s + nz * c
}
}

function addStreetlamp(m: Mesh, boxes: { rect: Rect; h: number }[], casters: { x: number; y: number; r: number; h: number }[]): void {
const [lx, lz] = STREETLAMP_AT
const pole = rgb(0x2f3436)
Expand Down
1 change: 1 addition & 0 deletions packages/kit/lab/world/section.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ export function createWorldSection(context: LabContext, world: DebugWorld): LabS
{ key: "playing", label: "Animate", group: "Clock & light" },
{ key: "skyLight", label: "Sun, moon and sky", group: "Clock & light", type: "range", min: 0, max: 2, step: 0.01, unit: "×" },
{ key: "exposureEV", label: "Camera exposure", group: "Clock & light", type: "range", min: -3, max: 4, step: 0.1, unit: "EV" },
{ key: "shadowLift", label: "Grade shadow lift", group: "Clock & light", subsection: "Physical", type: "range", min: 0, max: 1, step: 0.05, unit: "×", visible: s => s.lightModel === "physical" },

{ key: "screen", label: "Screen bench", group: "Screen" },
{ key: "programme", label: "Showing", group: "Screen", type: "select", options: PROGRAMME_OPTIONS, visible: s => s.screen === true },
Expand Down
45 changes: 43 additions & 2 deletions packages/kit/lab/world/world.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@ import { describe, expect, test } from "bun:test"
import { cloneFoliageParams } from "../../gpu/foliage"
import type { LabContext } from "../types"
import { WORLD_PRESETS } from "./presets"
import { CHARACTER_AT, WORLD_ORIGIN, buildWorld, worldClock, worldLights } from "./scene"
import { writeOrbitCamera } from "../stage"
import { CHARACTER_AT, SCREEN_AT, SCREEN_CAMERA, SCREEN_CHAIR, WORLD_ORIGIN, buildWorld, worldClock, worldLights } from "./scene"
import { WORLD_TABS, createWorldSection } from "./section"
import { WORLD_DEFAULTS, createDebugWorld } from "./world"
import { postShader } from "../../gpu/shaders"
import { validateWgsl } from "../../gpu/test-naga"
import { WORLD_DEFAULTS, WORLD_POST, createDebugWorld } from "./world"

const context = { device: {}, stage: { eye: new Float32Array(3), viewProj: new Float32Array(16), frameTarget() {}, updateCamera() {} }, particles: { count: 0 }, sync() {} } as unknown as LabContext
const make = () => {
Expand Down Expand Up @@ -81,6 +84,44 @@ describe("the Look and world lab", () => {
expect(targets.length).toBe(2)
})

test("the screen bench frames the set on the right third and the chair's lit front three-quarter on", () => {
const vp = new Float32Array(16), eye = new Float32Array(3)
const c = SCREEN_CAMERA
writeOrbitCamera(708, 564, vp, new Float32Array(16), eye, c.target, c.yaw, c.pitch, c.dist)
const ndc = (x: number, y: number, z: number) => {
const w = vp[3]! * x + vp[7]! * y + vp[11]! * z + vp[15]!
return [(vp[0]! * x + vp[4]! * y + vp[8]! * z + vp[12]!) / w, (vp[1]! * x + vp[5]! * y + vp[9]! * z + vp[13]!) / w]
}
const [ox, oz] = WORLD_ORIGIN
const [tvX, tvY] = ndc(SCREEN_AT.center[0], SCREEN_AT.center[1], SCREEN_AT.center[2])
expect(tvX).toBeGreaterThan(0.25)
expect(tvX).toBeLessThan(0.45)
expect(Math.abs(tvY!)).toBeLessThan(0.5)
const [chairX] = ndc(SCREEN_CHAIR.x - ox, 0.7, SCREEN_CHAIR.z - oz)
expect(chairX).toBeLessThan(-0.3)
expect(chairX).toBeGreaterThan(-0.75)
// The chair faces the glass less its turn; the camera sits 30–60° off that front.
const face = Math.atan2(SCREEN_AT.center[2] + oz - SCREEN_CHAIR.z, SCREEN_AT.center[0] + ox - SCREEN_CHAIR.x) + SCREEN_CHAIR.turn
const toEye = Math.atan2(eye[2]! + oz - SCREEN_CHAIR.z, eye[0]! + ox - SCREEN_CHAIR.x)
const off = Math.abs(Math.atan2(Math.sin(toEye - face), Math.cos(toEye - face))) * 180 / Math.PI
expect(off).toBeGreaterThan(30)
expect(off).toBeLessThan(60)
})

test("the world's composite is the kit's, with a shadow lift the screen bench turns off", () => {
validateWgsl(WORLD_POST)
const base = postShader(false)
expect(WORLD_POST).not.toMatch(/\bG\./)
// Only the uniform's name, its declaration and the lift line differ from the kit's composite.
const strip = (code: string) => code.replace(/grade\.frame\./g, "G.").split("\n").filter(l => !/binding\(4\)|struct Grade|frame:vec4f|look:vec4f|reservedLook|custom:vec4f|^\}$/.test(l))
const a = strip(base), b = strip(WORLD_POST)
expect(b.length).toBe(a.length)
expect(b.filter((l, i) => l !== a[i])).toEqual([expect.stringContaining("select(vec3f(0.006, 0.011, 0.028), vec3f(0.006, 0.011, 0.028) * grade.custom.y, grade.custom.x > 0.5)")])
expect(WORLD_DEFAULTS.shadowLift).toBe(1)
expect(WORLD_PRESETS.find(p => p.id === "screen-bench")!.values.shadowLift).toBe(0)
expect(WORLD_PRESETS.filter(p => "shadowLift" in p.values).map(p => p.id)).toEqual(["screen-bench"])
})

test("export and import round-trip a versioned preset, and a bad file changes nothing", () => {
const a = make()
a.section.applyPreset("autumn")
Expand Down
Loading
Loading