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