Finished up most documentation
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user