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