Finished up most documentation
This commit is contained in:
@@ -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`
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
|
||||
127
spikes/card-harness/scripts/shader-pack-preparation.mjs
Normal file
127
spikes/card-harness/scripts/shader-pack-preparation.mjs
Normal 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()
|
||||
}
|
||||
}
|
||||
191
spikes/card-harness/scripts/shader-pixel-client.mjs
Normal file
191
spikes/card-harness/scripts/shader-pixel-client.mjs
Normal 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 }),
|
||||
})
|
||||
}
|
||||
126
spikes/card-harness/scripts/shader-pixel-regression.mjs
Normal file
126
spikes/card-harness/scripts/shader-pixel-regression.mjs
Normal 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 })
|
||||
}
|
||||
29
spikes/card-harness/scripts/shader-wear-reference.mjs
Normal file
29
spikes/card-harness/scripts/shader-wear-reference.mjs
Normal 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
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user