Finished up most documentation

This commit is contained in:
2026-09-10 21:11:07 -07:00
parent 2cfa0ca690
commit 8355743ada
14 changed files with 1871 additions and 868 deletions

View File

@@ -6,10 +6,19 @@
- Approved: September 7, 2026
- Scope: card geometry, front-face materials, substrate distinction, foil, holographic response, and current finish-mask policy
- Follow-up approval: rigid pack prototype and artifact-free individual card inspection; retained as the foundation for the foil iteration below
- Linen follow-up: `linen-relief-v1-2026-09-10`, visually approved September 10, 2026
- Metal and wood follow-up: `metal-wood-relief-v1-2026-09-10`, visually approved September 10, 2026
- Not yet approved: the new foil treatment/choreography, lighting presets, mobile performance, or wear
These values represent the current visual baseline. Changes should be deliberate and compared against this revision rather than treated as incidental shader cleanup.
The approved September 10 depth follow-ups supersede the v4 linen, metal, and wood
surface responses below: raised-looking weave, recessed metal etching, and raised
wood grain with shader parallax, including Printed ink at oblique angles.
Paper, Plastic, card geometry, backs, and renderer settings retain the v4 baseline.
This visual approval is not
a mobile performance sign-off or approval of the remaining pack choreography.
## Geometry
The card is generated procedurally at runtime. Blender and GLB files are not required.
@@ -128,10 +137,21 @@ Finish and material are independent concepts, but combinations should exist only
- Roughness floor: `0.70`
- Interlaced thread frequency: `52 x 73`
- Relief amplitude: `0.005 * detail`
- Reflection strength: `0.025`
- Raised weave height span: `0.010 * min(detail, 1.5)` scene units
- Analytic thread slopes keep normal response readable at oblique angles
- Two procedural weave evaluations add bounded view-dependent parallax; artwork,
finish-mask, and wear UVs stay anchored
- Grazing denominator floor: `0.30`; detail is capped at `1.5` for linen relief
- Subpixel threads fade using screen-space derivatives
- Base reflection strength: `0.025`; broad thread-ridge highlight strength:
`0.025 + 0.055 * grazing`, modulated by resolved ridge height and surface detail
- Edge: `#a89772`, roughness `0.78`, metalness `0`, clear coat `0`
This is shader relief, not displaced geometry: the card silhouette and physical
thickness are unchanged. The Surface detail control scales the effect, including
turning it off at zero. Printed ink retains the thread lighting without requiring
a foil finish; foil and holographic coating formulas are unchanged.
### Plastic
- Roughness ceiling: `0.25`
@@ -142,9 +162,13 @@ Finish and material are independent concepts, but combinations should exist only
### Metal
- Visually approved September 10, 2026
- Roughness ceiling: `0.20`
- Artwork-driven etch: darker values are recessed more deeply
- Etch relief amplitude: `-0.0065 * depth * min(detail, 1.5)`
- Etch relief amplitude: `-0.010 * depth * min(detail, 1.5)` (previously `-0.0065`)
- Bounded predictor/corrector parallax shifts artwork and finish mask together;
grazing denominator floor `0.35`, fading within `0.025` UV of the card edges
- Directional recess shadow strength: `0.22`, scaled by detail and local ridge depth
- Cavity shading: `0.12` on the metal underprint and `0.06` on the combined printed result
- Metal underprint blend: `0.42`
- Edge: `#a8adb7`, roughness `0.20`, metalness `0.86`, clear coat `0.18`
@@ -158,11 +182,18 @@ Metal + Foil is a special intentional combination:
- Plating weight is capped at `0.34`
- Must not turn the illustration into opaque gold
Parallax and directional recess shading add depth cues beyond the stronger relief.
The existing underprint tint and foil/plating recipes are retained; the finish
mask follows the shifted artwork. Card thickness and geometry are unchanged.
### Wood
- Visually approved September 10, 2026
- Roughness floor: `0.48`
- Warped longitudinal grain frequency: `170`
- Relief amplitude: `0.007 * detail`
- Relief amplitude: `0.012 * min(detail, 1.5)` (previously `0.007 * detail`)
- Two procedural grain evaluations provide bounded parallax; grazing denominator
floor `0.35`. Artwork stays anchored; tint and relief use the same shifted grain.
- Warm tint blend: `0.25`
- Reflection strength: `0.09`
- Edge: `#6b3f21`, roughness `0.56`, metalness `0`, clear coat `0.08`

View File

@@ -66,6 +66,12 @@ Camera framing eases with the lift/exit so the deeper separation does not
unexpectedly enlarge the card. The edge mesh draws only extrusion side walls, not end caps beneath the
dedicated artwork faces, preventing depth-fighting bands at farther zoom distances.
Pack assets are loaded separately on first entry, with retry on failure.
Before enabling pack interaction, textures are uploaded and shaders are compiled
for the pouch, hidden card fronts, universal backs, and both neutral/revealed edge
materials using the scene's lighting and environment. This preparation does not
draw or reveal cards. The loading state lasts until it finishes; failures release
the new pack resources and keep retry available. Returning to an already prepared
pack retains its state without repeating this preparation.
Wrapper deformation is coalesced to once per rendered frame, including touch dents
and tear input. Fixed crease/crimp calculations are cached; unchanged pouch surfaces
@@ -73,6 +79,9 @@ and ribbons do not recalculate normals/bounds or upload vertex buffers. Hidden
wrappers do not deform. Pack UI updates are also coalesced and only write changed
values. These optimizations retain the same mesh resolution, deformation formulas,
materials, lighting, pixel ratio, and antialiasing.
Mint cards (`condition = 1`) bypass procedural wear calculations whose contribution
is zero. Worn cards use the original wear equations, and all finish/substrate
shading remains unchanged.
There is no sound, haptics, particles, cloth simulation, backend, rewards persistence,
pack progress persistence, or timeline/editor. This is a choreography proof, not an
@@ -86,12 +95,26 @@ Finish and substrate are separate:
- Finish controls printed ink, foil, or holographic coating.
- Substrate controls surface roughness/microtexture, underprint response, and edge/core appearance.
Paper uses fine, irregular fibers and a broad, weak highlight. Linen uses raised
interlaced threads with small shaded recesses. Plastic has a smooth clear-coat
highlight even in Printed ink mode. Wood uses warped longitudinal grain with
surface relief and a restrained warm tint. Metal retains its reflective
underprint and adds a shallow luminance-driven etch, with darker artwork
recessed slightly more than lighter artwork. Procedural detail fades below
Paper uses fine, irregular fibers and a broad, weak highlight. Linen uses raised-looking
interlaced threads with analytic slope lighting, a bounded two-sample procedural
parallax offset, and broad ridge highlights that remain visible in Printed ink
at oblique angles. Only the weave shifts with the view: artwork and lettering stay
anchored. Surface detail scales the effect; unresolved threads fade to avoid
shimmer. This is shader relief with no added geometry or change to the card's
silhouette, back, or thickness. This linen look was visually approved September 10,
2026 as `linen-relief-v1-2026-09-10`; its exact defaults are recorded in
[`CURRENT-DEFAULTS.md`](CURRENT-DEFAULTS.md#linen). No extra toggle or configuration
is required: selecting Linen applies it in Inspect, Lab, and the authored pack.
Plastic has a smooth clear-coat
highlight even in Printed ink mode. Wood uses raised longitudinal grain with
bounded procedural parallax, stronger relief, and a restrained warm tint.
The artwork stays anchored while grain shading and tint shift together.
Metal retains its reflective underprint and uses luminance-driven recesses:
bounded parallax shifts the artwork and finish mask together, with directional
groove shading to emphasize depth. The effect fades at the card edges.
Metal and wood depth were visually approved September 10, 2026 as
`metal-wood-relief-v1-2026-09-10`. Surface detail scales the relief; neither
material adds geometry or changes the card silhouette. Procedural detail fades below
pixel resolution to limit shimmer.
The universal card back uses fixed neutral material properties so front finish
and substrate choices do not spoil a pack reveal.
@@ -107,6 +130,11 @@ slightly raised champagne-metal polishing over the etched steel base.
- `blender_prototype/card_finish_material.py`
- `blender_prototype/render_premium_review.py`
The approved linen, metal, and wood depth follow-ups change runtime shader behavior,
not the source artwork, generated normal map, or Blender reference renders. The
asset manifest therefore retains its v4 reference revision; the scoped material
approvals and parameters are tracked in `CURRENT-DEFAULTS.md`.
The runtime shader and geometry are runtime implementations, not a claim of pixel-identical Blender output.
Live review removed only David's face, hand, and lamb ellipse cutouts. Color-based coverage and title-panel protection remain. Holographic color and angle response are preserved; reduced sheen applies only to foil.
@@ -131,8 +159,32 @@ npm test
These check exact pre-optimization wrapper geometry snapshots, surface update
counts, per-frame input batching, pause/resume, pinch handoff, restart, reduced
motion, and ordered reveals. They are CPU/state checks, not a real-device GPU or
touch-latency benchmark; mobile performance still needs on-device validation.
motion, ordered reveals, GPU preparation, and failure cleanup. They are CPU/state
checks, not a real-device GPU or touch-latency benchmark; mobile performance still
needs on-device validation.
WebGL pixel parity and pack shader-preparation checks require a local headless
Chromium browser with WebGL2 support, installed project dependencies, and generated
reference assets (`npm run assets`):
```sh
SHADER_BROWSER=/absolute/path/to/chromium \
SHADER_BROWSER_KIND=chromium \
node scripts/shader-pixel-regression.mjs
```
The Chromium runner uses SwiftShader software WebGL. Browsers are not installed
automatically; a missing browser, missing WebGL support, timeout, pixel mismatch,
or unexpected shader program causes a nonzero exit rather than a skipped check.
The pixel check compares 360 combinations of substrate, finish, condition, angle,
and lighting against the current shader with unconditional wear evaluation,
verifying that the mint fast path remains equivalent. A separately hash-verified
v4 reference checks that Paper and Plastic remain unchanged and that the approved
Linen, Metal, and Wood iterations produce a visual difference. The pack check exercises opening,
all three reveals, and restart under point, directional, and spot lighting,
checking for new shader programs after preparation. Software WebGL can validate
pixel parity and program reuse, but its timings are not mobile GPU benchmarks.
The asset-generation script uses cross-platform Node APIs and works on Windows and Linux. The retained `export:blender` script is an optional reference utility and is not part of the application pipeline.

View File

@@ -231,3 +231,114 @@ test('reduced-motion opening keeps the existing 70 ms transition duration', (t)
step(1)
assert.equal(pack.state, 'stackReady')
})
test('GPU preparation uploads unique textures and compiles hidden fronts and both edge looks', async (t) => {
const { pack, canvas } = setupPack(t)
pack.setActive(false)
const scene = new THREE.Scene()
scene.environment = pack.options.environment
const backTexture = new THREE.Texture()
pack.options.backMaterial.map = backTexture
const uploaded = []
const compiledEdges = []
let reveals = 0
canvas.addEventListener('packreveal', () => reveals++)
const before = geometryHash(pack.wrapper)
let finishCompile
const firstCompile = new Promise((resolve) => { finishCompile = resolve })
const renderer = {
initTexture(texture) { uploaded.push(texture) },
async compileAsync(root, camera, targetScene) {
assert.equal(root, pack.root)
assert.equal(camera, pack.camera)
assert.equal(targetScene, scene)
assert.equal(root.visible, false)
assert.equal(pack.state, 'sealed')
assert.equal(pack.cards.some((card) => card.front.visible), false)
assert.ok(pack.cards.every((card) => root.getObjectById(card.front.id)))
compiledEdges.push(pack.cards.map((card) => card.edge.clearcoat))
if (compiledEdges.length === 1) await firstCompile
},
}
let ready = false
const preparation = pack.prepareGPU(renderer, scene).then(() => { ready = true })
await Promise.resolve()
assert.equal(ready, false)
finishCompile()
await preparation
assert.equal(uploaded.length, new Set(uploaded).size)
assert.equal(uploaded.length, 9, '4 artwork/mask textures, normal, environment, back and 2 wrapper maps')
for (const { artwork, mask } of Object.values(pack.options.textures)) {
assert.ok(uploaded.includes(artwork))
assert.ok(uploaded.includes(mask))
}
assert.ok(uploaded.includes(backTexture))
assert.ok(uploaded.includes(pack.options.normalMap))
assert.ok(uploaded.includes(scene.environment))
assert.deepEqual(compiledEdges, [[0.02, 0.02, 0.02], [0, 0.02, 0.18]])
assert.deepEqual(pack.cards.map((card) => card.edge.clearcoat), [0.02, 0.02, 0.02])
assert.equal(geometryHash(pack.wrapper), before)
assert.equal(reveals, 0)
})
test('failed reveal-material warmup restores neutral edges and can be retried', async (t) => {
const { pack } = setupPack(t)
pack.setActive(false)
let compilations = 0
const failure = new Error('GPU compilation failed')
const renderer = {
initTexture() {},
async compileAsync() {
if (++compilations === 2) throw failure
},
}
const scene = new THREE.Scene()
await assert.rejects(pack.prepareGPU(renderer, scene), (error) => error === failure)
assert.deepEqual(pack.cards.map((card) => card.edge.clearcoat), [0.02, 0.02, 0.02])
assert.equal(pack.root.visible, false)
assert.equal(pack.state, 'sealed')
await pack.prepareGPU(renderer, scene)
assert.equal(compilations, 4)
})
test('GPU upload errors propagate and preparation cannot mutate an active pack', async (t) => {
const { pack } = setupPack(t)
const failure = new Error('GPU upload failed')
let uploads = 0
const renderer = {
initTexture() { uploads++; throw failure },
async compileAsync() { assert.fail('Compilation must not run after upload failure') },
}
const scene = new THREE.Scene()
await assert.rejects(pack.prepareGPU(renderer, scene), /before activating/)
assert.equal(uploads, 0)
pack.setActive(false)
await assert.rejects(pack.prepareGPU(renderer, scene), (error) => error === failure)
})
test('pack cleanup disposes owned resources without disposing shared or caller-owned textures', (t) => {
const { pack } = setupPack(t)
const scene = new THREE.Scene()
scene.add(pack.root)
const shared = [
pack.options.backMaterial, pack.options.normalMap, pack.options.environment,
...Object.values(pack.options.textures).flatMap(({ artwork, mask }) => [artwork, mask]),
]
const sharedSpies = shared.map((resource) => t.mock.method(resource, 'dispose'))
const geometries = new Set()
const materials = new Set()
pack.root.traverse((object) => {
if (!(object instanceof THREE.Mesh)) return
geometries.add(object.geometry)
materials.add(object.material)
})
materials.delete(pack.options.backMaterial)
const resources = [...geometries, ...materials,
...new Set(pack.wrapper.root.children.map((mesh) => mesh.material.map))]
const ownedSpies = resources.map((resource) => t.mock.method(resource, 'dispose'))
pack.dispose()
assert.equal(pack.root.parent, null)
assert.equal(pack.root.visible, false)
assert.ok(ownedSpies.every((spy) => spy.mock.callCount() === 1))
assert.ok(sharedSpies.every((spy) => spy.mock.callCount() === 0))
})

View File

@@ -0,0 +1,127 @@
import * as THREE from 'three'
import { PackOpening, packContents } from '../src/packOpening.ts'
import { createStudioCubeTexture } from '../src/cardMaterial.ts'
export async function verifyPackPreparation() {
const startedAt = performance.now()
const loader = new THREE.TextureLoader()
const paths = ['david-front', 'david-finish-mask', 'timothy-front', 'timothy-finish-mask', 'card-back', 'linen-normal']
const textures = await Promise.all(paths.map((path) => loader.loadAsync(`/reference/${path}.png`)))
for (const index of [0, 2, 4]) textures[index].colorSpace = THREE.SRGBColorSpace
textures[5].wrapS = textures[5].wrapT = THREE.RepeatWrapping
const environment = createStudioCubeTexture()
const results = []
try {
for (const lightType of ['Point', 'Directional', 'Spot']) {
// A fresh context prevents the preceding pixel test from accidentally warming pack shaders.
const renderer = new THREE.WebGLRenderer({ antialias: false })
renderer.setSize(192, 264)
renderer.outputColorSpace = THREE.SRGBColorSpace
renderer.toneMapping = THREE.ACESFilmicToneMapping
const shaderErrors = []
renderer.debug.onShaderError = (gl, program, vertex, fragment) => {
shaderErrors.push([gl.getProgramInfoLog(program), gl.getShaderInfoLog(vertex),
gl.getShaderInfoLog(fragment)].join('\n'))
}
const scene = new THREE.Scene()
scene.background = new THREE.Color('#0b0e14')
scene.environment = environment
const lightPosition = new THREE.Vector3(2.2, 2.5, 4.4)
const light = lightType === 'Point' ? new THREE.PointLight('#fff0d0', 34, 20, 1.7)
: lightType === 'Directional' ? new THREE.DirectionalLight('#fff0d0', 5.4)
: new THREE.SpotLight('#fff0d0', 54, 20, THREE.MathUtils.degToRad(28), 0.55, 1.7)
light.position.copy(lightPosition)
scene.add(light)
if (light.target) scene.add(light.target)
const backMaterial = new THREE.MeshPhysicalMaterial({
map: textures[4], roughness: 0.42, metalness: 0, clearcoat: 0.2,
clearcoatRoughness: 0.24, normalMap: null, side: THREE.DoubleSide,
})
const pack = new PackOpening({
canvas: renderer.domElement,
textures: {
David: { artwork: textures[0], mask: textures[1] },
Timothy: { artwork: textures[2], mask: textures[3] },
},
backMaterial, normalMap: textures[5], environment, lightPosition, onChange() {},
})
pack.resize(192, 264)
const revealed = []
renderer.domElement.addEventListener('packreveal', (event) => revealed.push(event.detail))
const originalNow = performance.now
try {
const programsBeforePreparation = renderer.info.programs.length
await pack.prepareGPU(renderer, scene)
if (pack.root.visible || pack.state !== 'sealed' || revealed.length) {
throw new Error('Preparation changed visibility or reveal state')
}
const preparedPrograms = new Set(renderer.info.programs.map((program) => program.id))
if (!preparedPrograms.size) throw new Error('Preparation compiled no programs')
const texturesAfterPreparation = renderer.info.memory.textures
const unexpectedPrograms = new Map()
const visitedStates = new Set()
let renderedFrames = 0
let maximumTextureCount = texturesAfterPreparation
const textureGrowth = []
let now = originalNow.call(performance)
performance.now = () => now
function frame() {
pack.tick(now)
renderer.render(scene, pack.camera)
renderedFrames++
visitedStates.add(pack.state)
if (renderer.info.memory.textures > maximumTextureCount) {
textureGrowth.push({ state: pack.state, count: renderer.info.memory.textures })
}
maximumTextureCount = Math.max(maximumTextureCount, renderer.info.memory.textures)
for (const program of renderer.info.programs) {
if (!preparedPrograms.has(program.id) && !unexpectedPrograms.has(program.id)) {
unexpectedPrograms.set(program.id, { id: program.id, name: program.name, firstState: pack.state })
}
}
const gl = renderer.getContext()
if (shaderErrors.length || gl.isContextLost() || gl.getError() !== gl.NO_ERROR) {
throw new Error(`Pack WebGL rendering failed: ${shaderErrors.join('\n')}`)
}
}
scene.add(pack.root)
pack.setActive(true)
frame()
const durations = { opening: 3200, lifting: 620, revealing: 820, advancing: 650 }
for (let action = 0; action < 10 && pack.state !== 'complete'; action++) {
pack.primary()
const duration = durations[pack.state]
if (!duration) throw new Error(`Unexpected animated state: ${pack.state}`)
const started = now
for (const progress of [0, 0.1, 0.25, 0.4, 0.5, 0.65, 0.8, 0.9, 1.001]) {
now = started + duration * progress
frame()
}
}
if (pack.state !== 'complete' || JSON.stringify(revealed.map(({ fixture, finish, substrate }) =>
({ fixture, finish, substrate }))) !== JSON.stringify(packContents)) {
throw new Error('First reveal cycle did not complete in authored order')
}
pack.restart()
frame()
results.push({
lightType, passed: unexpectedPrograms.size === 0, programsBeforePreparation,
programsAfterPreparation: preparedPrograms.size,
programsAfterRevealAndRestart: renderer.info.programs.length,
unexpectedPrograms: [...unexpectedPrograms.values()], texturesAfterPreparation,
maximumTextureCount, textureGrowth, renderedFrames, visitedStates: [...visitedStates], revealed,
})
} finally {
performance.now = originalNow
pack.dispose()
backMaterial.dispose()
renderer.dispose()
renderer.forceContextLoss()
}
}
return { passed: results.every((result) => result.passed),
durationMs: Math.round(performance.now() - startedAt), results }
} finally {
for (const texture of [...textures, environment]) texture.dispose()
}
}

View File

@@ -0,0 +1,191 @@
import * as THREE from 'three'
import { createCardMaterial, createStudioCubeTexture, applyMaterialControls } from '../src/cardMaterial.ts'
import { createCardGeometry } from '../src/cardGeometry.ts'
import { verifyPackPreparation } from './shader-pack-preparation.mjs'
const width = 192
const height = 264
const substrates = ['Paper', 'Linen', 'Plastic', 'Metal', 'Wood']
const finishes = ['Printed ink', 'Foil', 'Holographic']
const conditions = [1, 0.999, 0.7, 0]
const angles = [0, 75]
const lights = [
{ name: 'Point', mode: 0, position: [2.2, 2.5, 4.4] },
{ name: 'Directional', mode: 1, position: [-2.8, 3.1, 4.8] },
{ name: 'Spot', mode: 2, position: [3.6, 1.9, 3.6] },
]
async function compare() {
const started = performance.now()
const renderer = new THREE.WebGLRenderer({ antialias: false, alpha: true })
renderer.setSize(width, height)
renderer.setPixelRatio(1)
renderer.setClearColor(0, 0)
renderer.outputColorSpace = THREE.SRGBColorSpace
renderer.toneMapping = THREE.ACESFilmicToneMapping
const compileErrors = []
renderer.debug.onShaderError = (gl, program, vertex, fragment) => {
compileErrors.push([gl.getProgramInfoLog(program), gl.getShaderInfoLog(vertex),
gl.getShaderInfoLog(fragment)].join('\n'))
}
const gl = renderer.getContext()
const debugInfo = gl.getExtension('WEBGL_debug_renderer_info')
const gpu = {
version: gl.getParameter(gl.VERSION),
vendor: debugInfo ? gl.getParameter(debugInfo.UNMASKED_VENDOR_WEBGL) : gl.getParameter(gl.VENDOR),
renderer: debugInfo ? gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER),
userAgent: navigator.userAgent,
}
const loader = new THREE.TextureLoader()
const [artwork, mask, normal, referenceSource] = await Promise.all([
loader.loadAsync('/reference/david-front.png'),
loader.loadAsync('/reference/david-finish-mask.png'),
loader.loadAsync('/reference/linen-normal.png'),
fetch('/__shader_reference').then((response) => response.json()),
])
artwork.colorSpace = THREE.SRGBColorSpace
mask.colorSpace = THREE.NoColorSpace
normal.colorSpace = THREE.NoColorSpace
normal.wrapS = normal.wrapT = THREE.RepeatWrapping
const environment = createStudioCubeTexture()
const candidate = createCardMaterial(artwork, mask, normal, environment, new THREE.Vector3())
const reference = candidate.clone()
reference.fragmentShader = referenceSource.fragmentShader
const baseline = candidate.clone()
baseline.fragmentShader = referenceSource.baselineShader
const edge = new THREE.MeshBasicMaterial({ color: '#9c8c69' })
const card = createCardGeometry(candidate, candidate, edge)
const front = card.getObjectByName('CARD_FRONT')
// Isolate the changed face shader; unchanged edge/back pixels must not dilute coverage.
card.getObjectByName('CARD_EDGE').visible = false
card.getObjectByName('CARD_BACK').visible = false
const scene = new THREE.Scene()
scene.add(card)
const camera = new THREE.PerspectiveCamera(40, width / height, 0.1, 100)
camera.position.set(0, 0, 6.4)
camera.lookAt(0, 0, 0)
const target = new THREE.WebGLRenderTarget(width, height, {
type: THREE.UnsignedByteType,
format: THREE.RGBAFormat,
depthBuffer: true,
stencilBuffer: false,
})
target.texture.colorSpace = THREE.SRGBColorSpace
const currentPixels = new Uint8Array(width * height * 4)
const originalPixels = new Uint8Array(currentPixels.length)
const baselinePixels = new Uint8Array(currentPixels.length)
function render(material, buffer) {
front.material = material
renderer.setRenderTarget(target)
renderer.render(scene, camera)
renderer.readRenderTargetPixels(target, 0, 0, width, height, buffer)
if (compileErrors.length) throw new Error(compileErrors.join('\n'))
if (gl.isContextLost()) throw new Error('WebGL context lost')
const error = gl.getError()
if (error !== gl.NO_ERROR) throw new Error(`WebGL error: ${error}`)
}
const failures = []
const summary = { cases: 0, comparedPixels: 0, differingPixels: 0, differingChannels: 0, maxChannelDelta: 0 }
const byCondition = Object.fromEntries(conditions.map((value) => [value,
{ cases: 0, differingPixels: 0, maxChannelDelta: 0 }]))
let minimumCoveredPixels = Infinity
let negativeControl
const baselineComparison = { unchangedCases: 0, differingPixels: 0, changedPixels: { Linen: 0, Metal: 0, Wood: 0 } }
try {
for (const substrate of substrates) for (const finish of finishes) {
for (const condition of conditions) for (const angle of angles) for (const light of lights) {
const controls = {
substrate, finish, condition, imperfectionSeed: 81251, finishStrength: 0.6,
roughness: 0.23, normalStrength: 0.14, environmentIntensity: 0.7,
}
for (const material of [candidate, reference, baseline]) {
applyMaterialControls(material, controls)
material.uniforms.lightPosition.value.set(...light.position)
material.uniforms.lightMode.value = light.mode
}
card.rotation.set(0, THREE.MathUtils.degToRad(angle), 0)
render(reference, originalPixels)
render(candidate, currentPixels)
let differingPixels = 0
let maxChannelDelta = 0
let coveredPixels = 0
for (let i = 0; i < currentPixels.length; i += 4) {
if (originalPixels[i + 3]) coveredPixels++
let different = false
for (let channel = 0; channel < 4; channel++) {
const delta = Math.abs(originalPixels[i + channel] - currentPixels[i + channel])
if (delta) {
different = true
summary.differingChannels++
maxChannelDelta = Math.max(maxChannelDelta, delta)
}
}
if (different) differingPixels++
}
minimumCoveredPixels = Math.min(minimumCoveredPixels, coveredPixels)
if (coveredPixels < width * height * 0.05) throw new Error('Insufficient rendered card coverage')
summary.cases++
summary.comparedPixels += width * height
summary.differingPixels += differingPixels
summary.maxChannelDelta = Math.max(summary.maxChannelDelta, maxChannelDelta)
byCondition[condition].cases++
byCondition[condition].differingPixels += differingPixels
byCondition[condition].maxChannelDelta = Math.max(byCondition[condition].maxChannelDelta, maxChannelDelta)
if (differingPixels && failures.length < 30) {
failures.push({ substrate, finish, condition, angle, light: light.name, differingPixels, maxChannelDelta })
}
render(baseline, baselinePixels)
let changed = 0
for (let i = 0; i < currentPixels.length; i += 4) {
if (currentPixels.subarray(i, i + 4).some((value, channel) => value !== baselinePixels[i + channel])) changed++
}
if (Object.hasOwn(baselineComparison.changedPixels, substrate)) {
baselineComparison.changedPixels[substrate] += changed
} else {
baselineComparison.unchangedCases++
baselineComparison.differingPixels += changed
}
if (!negativeControl && condition === 1 && angle === 0) {
candidate.uniforms.condition.value = 0
render(candidate, currentPixels)
let changed = 0
for (let i = 0; i < currentPixels.length; i += 4) {
if (currentPixels.subarray(i, i + 4).some((value, channel) => value !== originalPixels[i + channel])) changed++
}
if (changed < 100) throw new Error('Negative control failed: fully worn and mint unexpectedly match')
negativeControl = { mintVsFullyWornDifferingPixels: changed }
}
}
// Keep the browser event loop responsive, without unbounded animation frames.
await new Promise((resolve) => setTimeout(resolve, 0))
}
return {
passed: summary.differingPixels === 0 && summary.cases === 360
&& baselineComparison.differingPixels === 0
&& Object.values(baselineComparison.changedPixels).every(value => value > 0),
gpu, resolution: [width, height], substrates, finishes, conditions, angles, lights,
seed: 81251, tolerance: 0, ...summary, byCondition, minimumCoveredPixels, negativeControl,
baselineComparison, failures, durationMs: Math.round(performance.now() - started),
}
} finally {
target.dispose()
candidate.dispose()
reference.dispose()
baseline.dispose()
edge.dispose()
card.traverse((object) => object.geometry?.dispose())
for (const texture of [artwork, mask, normal, environment]) texture.dispose()
renderer.dispose()
}
}
try {
const report = await compare()
report.packPreparation = await verifyPackPreparation()
report.passed &&= report.packPreparation.passed
await fetch('/__shader_pixels', { method: 'POST', body: JSON.stringify(report) })
} catch (error) {
await fetch('/__shader_pixels', {
method: 'POST', body: JSON.stringify({ passed: false, error: String(error), stack: error.stack }),
})
}

View File

@@ -0,0 +1,126 @@
// Run: node scripts/shader-pixel-regression.mjs
// Optional: SHADER_BROWSER=/path/to/firefox SHADER_TIMEOUT_MS=180000
// Chromium: SHADER_BROWSER=/path/to/chrome-headless-shell SHADER_BROWSER_KIND=chromium
// Hosts without user namespaces: SHADER_NO_SANDBOX=1 (local test content only).
// No automation dependency: the browser POSTs WebGL readback results to a loopback Vite server.
// Also checks that prepared packs create no new programs through reveal and restart.
import { spawn } from 'node:child_process'
import { once } from 'node:events'
import { readFile, mkdir, writeFile, rm } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { createServer } from 'vite'
import { originalFragment, baselineFragment, referenceCommit, referenceHash } from './shader-wear-reference.mjs'
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const profile = resolve(root, 'scripts', `.shader-browser-${process.pid}`)
const timeoutMs = Number(process.env.SHADER_TIMEOUT_MS ?? 180000)
if (!Number.isFinite(timeoutMs) || timeoutMs < 1000 || timeoutMs > 600000) {
throw new Error('SHADER_TIMEOUT_MS must be between 1000 and 600000')
}
const fragmentShader = originalFragment(await readFile(resolve(root, 'src/cardMaterial.ts'), 'utf8'))
const baselineShader = baselineFragment(root)
let resolveResult
let rejectResult
const result = new Promise((resolve, reject) => {
resolveResult = resolve
rejectResult = reject
})
// Attach a handler immediately; startup errors can precede awaiting the result.
result.catch(() => {})
let server
let browser
let timer
try {
await mkdir(profile)
await writeFile(resolve(profile, 'user.js'), [
'user_pref("webgl.force-enabled", true);',
'user_pref("webgl.disabled", false);',
'user_pref("browser.shell.checkDefaultBrowser", false);',
'user_pref("browser.startup.homepage_override.mstone", "ignore");',
'user_pref("datareporting.policy.dataSubmissionEnabled", false);',
].join('\n'))
server = await createServer({
root,
configFile: false,
cacheDir: resolve(profile, 'vite'),
logLevel: 'error',
server: { host: '127.0.0.1', port: 0, watch: null },
plugins: [{
name: 'shader-pixel-regression',
configureServer(vite) {
vite.middlewares.use((req, res, next) => {
if (req.url === '/__shader_reference') {
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ fragmentShader, baselineShader }))
} else if (req.url === '/__shader_pixels' && req.method === 'POST') {
let body = ''
req.on('data', (chunk) => {
body += chunk
if (body.length > 2_000_000) req.destroy()
})
req.on('end', () => {
try {
resolveResult(JSON.parse(body))
res.end('ok')
} catch (error) {
rejectResult(error)
res.statusCode = 400
res.end('Invalid JSON')
}
})
} else if (req.url === '/__shader_test') {
res.setHeader('Content-Type', 'text/html')
res.end('<!doctype html><title>Shader pixel regression</title><script type="module" src="/scripts/shader-pixel-client.mjs"></script>')
} else {
next()
}
})
},
}],
})
await server.listen()
const address = server.httpServer.address()
const url = `http://127.0.0.1:${address.port}/__shader_test`
const probe = await fetch(url)
if (!probe.ok) throw new Error(`Local test server failed: ${probe.status}`)
console.log(`Shader reference verified against ${referenceCommit} (${referenceHash})`)
console.log(`Serving ${url}; browser timeout ${timeoutMs}ms`)
timer = setTimeout(() => rejectResult(new Error('Browser pixel regression timed out')), timeoutMs)
const browserArgs = process.env.SHADER_BROWSER_KIND === 'chromium' ? [
'--headless', `--user-data-dir=${profile}`, '--no-first-run',
'--disable-background-networking', '--disable-dev-shm-usage',
'--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader', url,
] : ['--headless', '--no-remote', '--profile', profile, url]
if (process.env.SHADER_BROWSER_KIND === 'chromium' && process.env.SHADER_NO_SANDBOX === '1') {
browserArgs.unshift('--no-sandbox')
}
browser = spawn(process.env.SHADER_BROWSER ?? '/usr/bin/firefox', browserArgs, {
env: { ...process.env, TMPDIR: profile, MOZ_HEADLESS: '1' },
stdio: ['ignore', 'ignore', 'pipe'],
})
let browserLog = ''
browser.stderr.on('data', (chunk) => { browserLog = (browserLog + chunk).slice(-12000) })
browser.once('error', rejectResult)
browser.once('exit', (code) => rejectResult(new Error(`Browser exited with ${code}\n${browserLog}`)))
let report
try {
report = await result
} catch (error) {
console.error(browserLog)
throw error
}
console.log(JSON.stringify({ referenceCommit, referenceHash, ...report }, null, 2))
if (!report.passed) process.exitCode = 1
} finally {
clearTimeout(timer)
if (browser?.pid && browser.exitCode === null && browser.signalCode === null) {
const exited = once(browser, 'exit')
browser.kill('SIGTERM')
const forceKill = setTimeout(() => browser.kill('SIGKILL'), 3000)
await exited
clearTimeout(forceKill)
}
await server?.close()
await rm(profile, { recursive: true, force: true })
}

View File

@@ -0,0 +1,29 @@
import assert from 'node:assert/strict'
import { createHash } from 'node:crypto'
import { execFileSync } from 'node:child_process'
// SHA-256 of the whitespace-normalized fragment shader at this pre-fast-path commit.
export const referenceCommit = '2cfa0ca690134aa4988f01f0170e9e202b608c1c'
export const referenceHash = '5490249511bf2511ac78b6ef3b061fc8ef12ed42b255fe567954c16d5559f9b4'
export function baselineFragment(root) {
const source = execFileSync('git', [
'show', `${referenceCommit}:spikes/card-harness/src/cardMaterial.ts`,
], { cwd: root, encoding: 'utf8' })
const fragment = source.match(/const fragmentShader = `([\s\S]*?)`/)?.[1]
assert.ok(fragment, 'Cannot find original card fragment shader')
const hash = createHash('sha256').update(fragment.replace(/\s+/g, '')).digest('hex')
assert.equal(hash, referenceHash, 'Original shader reference must retain its recorded hash')
return fragment
}
export function originalFragment(source) {
const fragment = source.match(/const fragmentShader = `([\s\S]*?)`/)?.[1]
assert.ok(fragment, 'Cannot find card fragment shader')
const gate = /float edgeWear = 0\.0;\s*float scratchWear = 0\.0;\s*float scuffWear = 0\.0;\s*if \(condition < 1\.0\) \{([\s\S]*?)\}\s*(?=float wearDamage)/
assert.ok(gate.test(fragment), 'Expected the mint-only wear fast path')
const original = fragment.replace(gate, (_, body) => body.replace(
/^(\s*)(edgeWear|scratchWear|scuffWear) =/gm, '$1float $2 =',
))
return original
}

View File

@@ -1,4 +1,5 @@
import * as THREE from 'three'
import { cardSceneDimensions } from './cardGeometry'
export type FinishName = 'Printed ink' | 'Foil' | 'Holographic'
export type SubstrateName = 'Paper' | 'Linen' | 'Plastic' | 'Metal' | 'Wood'
@@ -98,6 +99,7 @@ const fragmentShader = `
uniform float normalStrength;
uniform float condition;
uniform float imperfectionSeed;
uniform vec2 cardFaceSize;
uniform int finishMode;
uniform int substrateMode;
uniform int lightMode;
@@ -132,6 +134,25 @@ const fragmentShader = `
return sin(phase) * visibility;
}
// Height and analytic UV slopes keep thread ridges lit even at oblique angles.
vec3 linenRelief(vec2 uv, vec4 visibility) {
vec2 frequency = vec2(52.0, 73.0) * 6.2831853;
vec2 phase = uv * frequency;
vec2 thread = sin(phase) * visibility.xy;
vec2 crossing = sin(phase * 0.5) * visibility.zw;
float over = 0.5 + 0.5 * crossing.x * crossing.y;
vec2 threadSlope = cos(phase) * frequency * visibility.xy;
vec2 crossingSlope = cos(phase * 0.5) * frequency * 0.5 * visibility.zw;
vec2 overSlope = 0.5 * crossingSlope * crossing.yx;
vec2 slope = vec2((1.0 - over) * threadSlope.x, over * threadSlope.y)
+ (thread.y - thread.x) * overSlope;
return vec3(0.5 + 0.5 * mix(thread.x, thread.y, over), slope * 0.5);
}
float metalDepth(vec3 ink) {
return smoothstep(0.08, 0.92, 1.0 - dot(ink, vec3(0.2126, 0.7152, 0.0722)));
}
float woodGrain(vec2 uv) {
float warp = fiberNoise(uv * vec2(4.0, 2.5));
float phase = uv.x * 170.0 + warp * 16.0 + sin(uv.y * 9.0) * 2.5;
@@ -208,28 +229,55 @@ const fragmentShader = `
vec2 artworkUv = vUv;
vec4 artworkSample = texture2D(artwork, artworkUv);
float mask = texture2D(finishMask, artworkUv).r;
float wearAmount = pow(clamp(1.0 - condition, 0.0, 1.0), 1.15);
float edgeDistance = min(
min(artworkUv.x, 1.0 - artworkUv.x),
min(artworkUv.y, 1.0 - artworkUv.y)
);
float edgeNoise = fiberNoise(artworkUv * vec2(43.0, 61.0) + imperfectionSeed * 0.0007);
float edgeWidth = mix(0.0015, 0.032, wearAmount) * mix(0.65, 1.25, edgeNoise);
float edgeWear = (1.0 - smoothstep(edgeWidth * 0.45, edgeWidth, edgeDistance)) * wearAmount;
float scratchWear = max(
scratchLine(artworkUv, 1.0),
max(scratchLine(artworkUv, 2.0), scratchLine(artworkUv, 3.0))
) * smoothstep(0.04, 0.48, wearAmount);
float scuffWear = max(
scuffMark(artworkUv, 1.0),
scuffMark(artworkUv, 2.0)
) * smoothstep(0.18, 0.82, wearAmount);
float metalRelief = 0.010 * min(normalStrength / 0.14, 1.5);
float metalBoundary = 1.0;
if (substrateMode == 3) {
vec2 edgeDistance = min(vUv, 1.0 - vUv);
metalBoundary = smoothstep(0.0, 0.025, min(edgeDistance.x, edgeDistance.y));
vec3 view = normalize(cameraPosition - vWorldPosition);
vec2 alongSurface = vec2(dot(view, normalize(vWorldTangent)),
dot(view, normalize(vWorldBitangent)));
vec2 ray = alongSurface / max(abs(dot(view, normalize(vWorldNormal))), 0.35)
* metalRelief * metalBoundary / cardFaceSize;
float exposure = finishMode == 1 ? 1.0 - mask * 0.25 : 1.0;
float depth = metalDepth(artworkSample.rgb) * exposure;
// A bounded predictor/corrector adds recess parallax without ray marching.
vec2 predictedUv = clamp(vUv - ray * depth, vec2(0.0), vec2(1.0));
float predictedDepth = metalDepth(texture2D(artwork, predictedUv).rgb) * exposure;
artworkUv = clamp(vUv - ray * (depth + predictedDepth) * 0.5, vec2(0.0), vec2(1.0));
artworkSample = texture2D(artwork, artworkUv);
mask = texture2D(finishMask, artworkUv).r;
}
float edgeWear = 0.0;
float scratchWear = 0.0;
float scuffWear = 0.0;
if (condition < 1.0) {
float wearAmount = pow(clamp(1.0 - condition, 0.0, 1.0), 1.15);
float edgeDistance = min(
min(artworkUv.x, 1.0 - artworkUv.x),
min(artworkUv.y, 1.0 - artworkUv.y)
);
float edgeNoise = fiberNoise(artworkUv * vec2(43.0, 61.0) + imperfectionSeed * 0.0007);
float edgeWidth = mix(0.0015, 0.032, wearAmount) * mix(0.65, 1.25, edgeNoise);
edgeWear = (1.0 - smoothstep(edgeWidth * 0.45, edgeWidth, edgeDistance)) * wearAmount;
scratchWear = max(
scratchLine(artworkUv, 1.0),
max(scratchLine(artworkUv, 2.0), scratchLine(artworkUv, 3.0))
) * smoothstep(0.04, 0.48, wearAmount);
scuffWear = max(
scuffMark(artworkUv, 1.0),
scuffMark(artworkUv, 2.0)
) * smoothstep(0.18, 0.82, wearAmount);
}
float wearDamage = max(edgeWear, max(scratchWear * 0.8, scuffWear * 0.55));
mask *= 1.0 - wearDamage * 0.82;
vec3 normalSample = texture2D(normalMap, artworkUv * 2.2).xyz * 2.0 - 1.0;
float detail = normalStrength / 0.14;
float surfaceHeight = 0.0;
float surfaceShade = 1.0;
vec2 linenSlope = vec2(0.0);
float linenRidge = 0.0;
float woodSurfaceGrain = 0.5;
float metalEtchDepth = 0.0;
float metalEtchExposure = 1.0;
if (substrateMode == 0) {
@@ -240,30 +288,45 @@ const fragmentShader = `
surfaceShade = 1.0 + fibers * 0.045 * min(detail, 1.5);
}
if (substrateMode == 1) {
vec2 thread = artworkUv * vec2(52.0, 73.0) * 6.2831853;
float warp = filteredWave(thread.x);
float weft = filteredWave(thread.y);
float overUnder = filteredWave(thread.x * 0.5) * filteredWave(thread.y * 0.5);
float weave = mix(warp, weft, 0.5 + 0.5 * overUnder);
surfaceHeight = weave * 0.005 * detail;
surfaceShade = 1.0 - (1.0 - weave) * 0.055 * min(detail, 1.5);
vec2 phase = artworkUv * vec2(52.0, 73.0) * 6.2831853;
vec2 footprint = fwidth(phase);
vec4 visibility = 1.0 - smoothstep(vec4(0.7), vec4(3.0),
vec4(footprint, footprint * 0.5));
float relief = 0.010 * min(detail, 1.5);
vec3 weave = linenRelief(artworkUv, visibility);
vec3 view = normalize(cameraPosition - vWorldPosition);
vec2 alongSurface = vec2(dot(view, normalize(vWorldTangent)),
dot(view, normalize(vWorldBitangent)));
// Offset only the weave, not the illustration, mask or printed lettering.
vec2 parallax = alongSurface / max(abs(dot(view, normalize(vWorldNormal))), 0.3)
* (weave.x - 0.5) * relief / cardFaceSize;
weave = linenRelief(artworkUv + parallax, visibility);
linenSlope = weave.yz * relief / cardFaceSize;
linenRidge = smoothstep(0.45, 0.9, weave.x) * min(visibility.x, visibility.y) * min(detail, 1.5);
surfaceShade = 1.0 - (1.0 - weave.x) * 0.11 * min(detail, 1.5);
}
if (substrateMode == 3) {
float artworkLuminance = dot(artworkSample.rgb, vec3(0.2126, 0.7152, 0.0722));
metalEtchDepth = smoothstep(0.08, 0.92, 1.0 - artworkLuminance);
metalEtchDepth = metalDepth(artworkSample.rgb);
if (finishMode == 1) {
metalEtchExposure = 1.0 - mask * 0.25;
}
surfaceHeight = -metalEtchDepth * metalEtchExposure * 0.0065 * min(detail, 1.5);
surfaceHeight = -metalEtchDepth * metalEtchExposure * metalRelief;
if (finishMode == 1) {
surfaceHeight += mask * 0.0005 * min(detail, 1.5);
}
surfaceShade = 1.0 - metalEtchDepth * metalEtchExposure * 0.035;
}
if (substrateMode == 4) {
float relief = 0.012 * min(detail, 1.5);
vec3 view = normalize(cameraPosition - vWorldPosition);
vec2 alongSurface = vec2(dot(view, normalize(vWorldTangent)),
dot(view, normalize(vWorldBitangent)));
float grain = woodGrain(artworkUv);
surfaceHeight = grain * 0.007 * detail;
surfaceShade = 0.88 + grain * 0.12;
vec2 parallax = alongSurface / max(abs(dot(view, normalize(vWorldNormal))), 0.35)
* (grain - 0.5) * relief / cardFaceSize;
woodSurfaceGrain = woodGrain(artworkUv + parallax);
surfaceHeight = woodSurfaceGrain * relief;
surfaceShade = 0.88 + woodSurfaceGrain * 0.12;
}
surfaceHeight -= scratchWear * 0.0018 + scuffWear * 0.0007;
float substrateNormalScale = substrateMode == 3 ? 0.06 : 0.12;
@@ -273,6 +336,10 @@ const fragmentShader = `
vWorldBitangent * normalSample.y * normalStrength * substrateNormalScale
);
normal = reliefNormal(normal, surfaceHeight);
if (substrateMode == 1) {
normal = normalize(normal - normalize(vWorldTangent) * linenSlope.x
- normalize(vWorldBitangent) * linenSlope.y);
}
vec3 viewDirection = normalize(cameraPosition - vWorldPosition);
vec3 lightVector = lightPosition - vWorldPosition;
@@ -316,6 +383,12 @@ const fragmentShader = `
float reflectionStrength = substrateMode == 1 ? 0.025 : (substrateMode == 4 ? 0.09 : 0.045);
printedInk += lightColor * substrateSpecular * reflectionStrength;
}
if (substrateMode == 1) {
float threadHighlight = pow(max(dot(normal, halfVector), 0.0), 24.0);
float grazing = 1.0 - max(dot(normalize(vWorldNormal), viewDirection), 0.0);
printedInk += lightColor * threadHighlight * diffuse * lightIntensity * attenuation
* linenRidge * (0.025 + 0.055 * grazing);
}
if (substrateMode == 2) {
float reflectedFraction = min(0.32, dielectricFresnel * environmentIntensity);
printedInk = printedInk * (1.0 - reflectedFraction) + environment * reflectedFraction;
@@ -324,6 +397,13 @@ const fragmentShader = `
if (substrateMode == 3) {
float visibleEtch = metalEtchDepth * metalEtchExposure;
float etchEdge = smoothstep(0.002, 0.045, fwidth(visibleEtch));
vec2 alongLight = vec2(dot(lightDirection, normalize(vWorldTangent)),
dot(lightDirection, normalize(vWorldBitangent)));
vec2 ridgeUv = clamp(artworkUv + alongLight * metalRelief * metalBoundary / cardFaceSize,
vec2(0.0), vec2(1.0));
float ridgeDepth = metalDepth(texture2D(artwork, ridgeUv).rgb) * metalEtchExposure;
float recessShadow = smoothstep(0.02, 0.25, visibleEtch - ridgeDepth)
* min(detail, 1.5) * metalBoundary;
vec3 metalUnderprint = mix(
artworkSample.rgb * vec3(0.72, 0.76, 0.82),
environment,
@@ -334,10 +414,10 @@ const fragmentShader = `
printedInk = mix(printedInk, metalUnderprint, 0.42);
printedInk *= 1.0 - visibleEtch * 0.06;
printedInk += lightColor * etchEdge * 0.025;
printedInk *= 1.0 - recessShadow * 0.22;
}
if (substrateMode == 4) {
float grain = woodGrain(artworkUv);
vec3 woodTint = mix(vec3(0.72, 0.40, 0.18), vec3(1.0, 0.86, 0.65), grain);
vec3 woodTint = mix(vec3(0.72, 0.40, 0.18), vec3(1.0, 0.86, 0.65), woodSurfaceGrain);
printedInk *= mix(vec3(1.0), woodTint, 0.25);
}
if (finishMode == 0) {
@@ -455,6 +535,7 @@ export function createCardMaterial(
normalStrength: { value: 0.14 },
condition: { value: 1.0 },
imperfectionSeed: { value: 81251 },
cardFaceSize: { value: new THREE.Vector2(cardSceneDimensions.width, cardSceneDimensions.height) },
finishMode: { value: 2 },
substrateMode: { value: 0 },
lightMode: { value: 0 },

View File

@@ -173,5 +173,12 @@ export function createFoilWrapper() {
strip.visible = detach < 1
}
deform(0, 0, 0, new THREE.Vector3())
return { root, deform }
function dispose() {
for (const { mesh } of sheets) mesh.geometry.dispose()
stripGeometry.dispose()
frontTexture.dispose()
backMaterial.map!.dispose()
for (const material of [frontMaterial, backMaterial, stripMaterial]) material.dispose()
}
return { root, deform, dispose }
}

View File

@@ -720,6 +720,8 @@ async function preparePack() {
packLoading = true
packError = undefined
updatePackUI()
let preparedPack: PackOpening | undefined
let textures: THREE.Texture[] = []
try {
// Separate texture ownership keeps local artwork, experiments and fixture disposal independent.
const paths = [fixtureAssets.David.artwork, fixtureAssets.David.mask,
@@ -732,12 +734,12 @@ async function preparePack() {
}
throw failure.reason
}
const textures = results.map((result, index) => {
textures = results.map((result, index) => {
if (result.status !== 'fulfilled') throw new Error(`Missing pack asset: ${paths[index]}`)
configureFrontTexture(result.value, index % 2 ? THREE.NoColorSpace : THREE.SRGBColorSpace)
return result.value
})
pack = new PackOpening({
preparedPack = new PackOpening({
canvas,
textures: {
David: { artwork: textures[0], mask: textures[1] },
@@ -749,12 +751,24 @@ async function preparePack() {
lightPosition,
onChange: () => { packUIDirty = true },
})
scene.add(pack.root)
pack.syncLighting(frontMaterial)
pack.setActive(mode === 'Pack')
pack.resize(canvas.clientWidth, canvas.clientHeight)
preparedPack.resize(canvas.clientWidth, canvas.clientHeight)
let preparedEnvironment: THREE.Texture | null
let preparedLightType: LightType
do {
preparedEnvironment = scene.environment
preparedLightType = controls.lightType
preparedPack.syncLighting(frontMaterial)
await preparedPack.prepareGPU(renderer, scene)
} while (scene.environment !== preparedEnvironment || controls.lightType !== preparedLightType)
preparedPack.syncLighting(frontMaterial)
preparedPack.resize(canvas.clientWidth, canvas.clientHeight)
preparedPack.setActive(mode === 'Pack')
scene.add(preparedPack.root)
pack = preparedPack
} catch (error) {
packError = `Pack assets unavailable: ${error instanceof Error ? error.message : String(error)}`
preparedPack?.dispose()
for (const texture of textures) texture.dispose()
packError = `Pack unavailable: ${error instanceof Error ? error.message : String(error)}`
console.error('Could not prepare pack prototype', error)
} finally {
packLoading = false

View File

@@ -186,6 +186,54 @@ export class PackOpening {
}
}
async prepareGPU(renderer: THREE.WebGLRenderer, scene: THREE.Scene) {
if (this.active) throw new Error('Prepare pack GPU resources before activating the pack')
const textures = new Set<THREE.Texture>()
if (scene.environment) textures.add(scene.environment)
this.root.traverse((object) => {
if (!(object instanceof THREE.Mesh)) return
const materials = Array.isArray(object.material) ? object.material : [object.material]
for (const material of materials) {
for (const value of Object.values(material)) {
if (value instanceof THREE.Texture) textures.add(value)
}
if (material instanceof THREE.ShaderMaterial) {
for (const uniform of Object.values(material.uniforms)) {
if (uniform.value instanceof THREE.Texture) textures.add(uniform.value)
}
}
}
})
for (const texture of textures) renderer.initTexture(texture)
// compileAsync visits hidden fronts without drawing them or changing reveal state.
await renderer.compileAsync(this.root, this.camera, scene)
try {
this.cards.forEach((card, index) => {
applyEdgeMaterialControls(card.edge, { ...packContents[index], condition: 1 })
})
await renderer.compileAsync(this.root, this.camera, scene)
} finally {
for (const card of this.cards) applyEdgeMaterialControls(card.edge, { substrate: 'Paper', condition: 1 })
}
}
dispose() {
this.clearPointers()
this.active = false
this.root.visible = false
this.root.removeFromParent()
this.wrapper.dispose()
const geometries = new Set<THREE.BufferGeometry>()
for (const card of this.cards) {
card.object.traverse((object) => {
if (object instanceof THREE.Mesh) geometries.add(object.geometry)
})
card.material.dispose()
card.edge.dispose()
}
for (const geometry of geometries) geometry.dispose()
}
setActive(active: boolean) {
if (!active) this.pause(performance.now())
this.clearPointers()