192 lines
8.9 KiB
JavaScript
192 lines
8.9 KiB
JavaScript
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 }),
|
|
})
|
|
}
|