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

@@ -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 }),
})
}