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

@@ -1,4 +1,5 @@
import * as THREE from 'three'
import { cardSceneDimensions } from './cardGeometry'
export type FinishName = 'Printed ink' | 'Foil' | 'Holographic'
export type SubstrateName = 'Paper' | 'Linen' | 'Plastic' | 'Metal' | 'Wood'
@@ -98,6 +99,7 @@ const fragmentShader = `
uniform float normalStrength;
uniform float condition;
uniform float imperfectionSeed;
uniform vec2 cardFaceSize;
uniform int finishMode;
uniform int substrateMode;
uniform int lightMode;
@@ -132,6 +134,25 @@ const fragmentShader = `
return sin(phase) * visibility;
}
// Height and analytic UV slopes keep thread ridges lit even at oblique angles.
vec3 linenRelief(vec2 uv, vec4 visibility) {
vec2 frequency = vec2(52.0, 73.0) * 6.2831853;
vec2 phase = uv * frequency;
vec2 thread = sin(phase) * visibility.xy;
vec2 crossing = sin(phase * 0.5) * visibility.zw;
float over = 0.5 + 0.5 * crossing.x * crossing.y;
vec2 threadSlope = cos(phase) * frequency * visibility.xy;
vec2 crossingSlope = cos(phase * 0.5) * frequency * 0.5 * visibility.zw;
vec2 overSlope = 0.5 * crossingSlope * crossing.yx;
vec2 slope = vec2((1.0 - over) * threadSlope.x, over * threadSlope.y)
+ (thread.y - thread.x) * overSlope;
return vec3(0.5 + 0.5 * mix(thread.x, thread.y, over), slope * 0.5);
}
float metalDepth(vec3 ink) {
return smoothstep(0.08, 0.92, 1.0 - dot(ink, vec3(0.2126, 0.7152, 0.0722)));
}
float woodGrain(vec2 uv) {
float warp = fiberNoise(uv * vec2(4.0, 2.5));
float phase = uv.x * 170.0 + warp * 16.0 + sin(uv.y * 9.0) * 2.5;
@@ -208,28 +229,55 @@ const fragmentShader = `
vec2 artworkUv = vUv;
vec4 artworkSample = texture2D(artwork, artworkUv);
float mask = texture2D(finishMask, artworkUv).r;
float wearAmount = pow(clamp(1.0 - condition, 0.0, 1.0), 1.15);
float edgeDistance = min(
min(artworkUv.x, 1.0 - artworkUv.x),
min(artworkUv.y, 1.0 - artworkUv.y)
);
float edgeNoise = fiberNoise(artworkUv * vec2(43.0, 61.0) + imperfectionSeed * 0.0007);
float edgeWidth = mix(0.0015, 0.032, wearAmount) * mix(0.65, 1.25, edgeNoise);
float edgeWear = (1.0 - smoothstep(edgeWidth * 0.45, edgeWidth, edgeDistance)) * wearAmount;
float scratchWear = max(
scratchLine(artworkUv, 1.0),
max(scratchLine(artworkUv, 2.0), scratchLine(artworkUv, 3.0))
) * smoothstep(0.04, 0.48, wearAmount);
float scuffWear = max(
scuffMark(artworkUv, 1.0),
scuffMark(artworkUv, 2.0)
) * smoothstep(0.18, 0.82, wearAmount);
float metalRelief = 0.010 * min(normalStrength / 0.14, 1.5);
float metalBoundary = 1.0;
if (substrateMode == 3) {
vec2 edgeDistance = min(vUv, 1.0 - vUv);
metalBoundary = smoothstep(0.0, 0.025, min(edgeDistance.x, edgeDistance.y));
vec3 view = normalize(cameraPosition - vWorldPosition);
vec2 alongSurface = vec2(dot(view, normalize(vWorldTangent)),
dot(view, normalize(vWorldBitangent)));
vec2 ray = alongSurface / max(abs(dot(view, normalize(vWorldNormal))), 0.35)
* metalRelief * metalBoundary / cardFaceSize;
float exposure = finishMode == 1 ? 1.0 - mask * 0.25 : 1.0;
float depth = metalDepth(artworkSample.rgb) * exposure;
// A bounded predictor/corrector adds recess parallax without ray marching.
vec2 predictedUv = clamp(vUv - ray * depth, vec2(0.0), vec2(1.0));
float predictedDepth = metalDepth(texture2D(artwork, predictedUv).rgb) * exposure;
artworkUv = clamp(vUv - ray * (depth + predictedDepth) * 0.5, vec2(0.0), vec2(1.0));
artworkSample = texture2D(artwork, artworkUv);
mask = texture2D(finishMask, artworkUv).r;
}
float edgeWear = 0.0;
float scratchWear = 0.0;
float scuffWear = 0.0;
if (condition < 1.0) {
float wearAmount = pow(clamp(1.0 - condition, 0.0, 1.0), 1.15);
float edgeDistance = min(
min(artworkUv.x, 1.0 - artworkUv.x),
min(artworkUv.y, 1.0 - artworkUv.y)
);
float edgeNoise = fiberNoise(artworkUv * vec2(43.0, 61.0) + imperfectionSeed * 0.0007);
float edgeWidth = mix(0.0015, 0.032, wearAmount) * mix(0.65, 1.25, edgeNoise);
edgeWear = (1.0 - smoothstep(edgeWidth * 0.45, edgeWidth, edgeDistance)) * wearAmount;
scratchWear = max(
scratchLine(artworkUv, 1.0),
max(scratchLine(artworkUv, 2.0), scratchLine(artworkUv, 3.0))
) * smoothstep(0.04, 0.48, wearAmount);
scuffWear = max(
scuffMark(artworkUv, 1.0),
scuffMark(artworkUv, 2.0)
) * smoothstep(0.18, 0.82, wearAmount);
}
float wearDamage = max(edgeWear, max(scratchWear * 0.8, scuffWear * 0.55));
mask *= 1.0 - wearDamage * 0.82;
vec3 normalSample = texture2D(normalMap, artworkUv * 2.2).xyz * 2.0 - 1.0;
float detail = normalStrength / 0.14;
float surfaceHeight = 0.0;
float surfaceShade = 1.0;
vec2 linenSlope = vec2(0.0);
float linenRidge = 0.0;
float woodSurfaceGrain = 0.5;
float metalEtchDepth = 0.0;
float metalEtchExposure = 1.0;
if (substrateMode == 0) {
@@ -240,30 +288,45 @@ const fragmentShader = `
surfaceShade = 1.0 + fibers * 0.045 * min(detail, 1.5);
}
if (substrateMode == 1) {
vec2 thread = artworkUv * vec2(52.0, 73.0) * 6.2831853;
float warp = filteredWave(thread.x);
float weft = filteredWave(thread.y);
float overUnder = filteredWave(thread.x * 0.5) * filteredWave(thread.y * 0.5);
float weave = mix(warp, weft, 0.5 + 0.5 * overUnder);
surfaceHeight = weave * 0.005 * detail;
surfaceShade = 1.0 - (1.0 - weave) * 0.055 * min(detail, 1.5);
vec2 phase = artworkUv * vec2(52.0, 73.0) * 6.2831853;
vec2 footprint = fwidth(phase);
vec4 visibility = 1.0 - smoothstep(vec4(0.7), vec4(3.0),
vec4(footprint, footprint * 0.5));
float relief = 0.010 * min(detail, 1.5);
vec3 weave = linenRelief(artworkUv, visibility);
vec3 view = normalize(cameraPosition - vWorldPosition);
vec2 alongSurface = vec2(dot(view, normalize(vWorldTangent)),
dot(view, normalize(vWorldBitangent)));
// Offset only the weave, not the illustration, mask or printed lettering.
vec2 parallax = alongSurface / max(abs(dot(view, normalize(vWorldNormal))), 0.3)
* (weave.x - 0.5) * relief / cardFaceSize;
weave = linenRelief(artworkUv + parallax, visibility);
linenSlope = weave.yz * relief / cardFaceSize;
linenRidge = smoothstep(0.45, 0.9, weave.x) * min(visibility.x, visibility.y) * min(detail, 1.5);
surfaceShade = 1.0 - (1.0 - weave.x) * 0.11 * min(detail, 1.5);
}
if (substrateMode == 3) {
float artworkLuminance = dot(artworkSample.rgb, vec3(0.2126, 0.7152, 0.0722));
metalEtchDepth = smoothstep(0.08, 0.92, 1.0 - artworkLuminance);
metalEtchDepth = metalDepth(artworkSample.rgb);
if (finishMode == 1) {
metalEtchExposure = 1.0 - mask * 0.25;
}
surfaceHeight = -metalEtchDepth * metalEtchExposure * 0.0065 * min(detail, 1.5);
surfaceHeight = -metalEtchDepth * metalEtchExposure * metalRelief;
if (finishMode == 1) {
surfaceHeight += mask * 0.0005 * min(detail, 1.5);
}
surfaceShade = 1.0 - metalEtchDepth * metalEtchExposure * 0.035;
}
if (substrateMode == 4) {
float relief = 0.012 * min(detail, 1.5);
vec3 view = normalize(cameraPosition - vWorldPosition);
vec2 alongSurface = vec2(dot(view, normalize(vWorldTangent)),
dot(view, normalize(vWorldBitangent)));
float grain = woodGrain(artworkUv);
surfaceHeight = grain * 0.007 * detail;
surfaceShade = 0.88 + grain * 0.12;
vec2 parallax = alongSurface / max(abs(dot(view, normalize(vWorldNormal))), 0.35)
* (grain - 0.5) * relief / cardFaceSize;
woodSurfaceGrain = woodGrain(artworkUv + parallax);
surfaceHeight = woodSurfaceGrain * relief;
surfaceShade = 0.88 + woodSurfaceGrain * 0.12;
}
surfaceHeight -= scratchWear * 0.0018 + scuffWear * 0.0007;
float substrateNormalScale = substrateMode == 3 ? 0.06 : 0.12;
@@ -273,6 +336,10 @@ const fragmentShader = `
vWorldBitangent * normalSample.y * normalStrength * substrateNormalScale
);
normal = reliefNormal(normal, surfaceHeight);
if (substrateMode == 1) {
normal = normalize(normal - normalize(vWorldTangent) * linenSlope.x
- normalize(vWorldBitangent) * linenSlope.y);
}
vec3 viewDirection = normalize(cameraPosition - vWorldPosition);
vec3 lightVector = lightPosition - vWorldPosition;
@@ -316,6 +383,12 @@ const fragmentShader = `
float reflectionStrength = substrateMode == 1 ? 0.025 : (substrateMode == 4 ? 0.09 : 0.045);
printedInk += lightColor * substrateSpecular * reflectionStrength;
}
if (substrateMode == 1) {
float threadHighlight = pow(max(dot(normal, halfVector), 0.0), 24.0);
float grazing = 1.0 - max(dot(normalize(vWorldNormal), viewDirection), 0.0);
printedInk += lightColor * threadHighlight * diffuse * lightIntensity * attenuation
* linenRidge * (0.025 + 0.055 * grazing);
}
if (substrateMode == 2) {
float reflectedFraction = min(0.32, dielectricFresnel * environmentIntensity);
printedInk = printedInk * (1.0 - reflectedFraction) + environment * reflectedFraction;
@@ -324,6 +397,13 @@ const fragmentShader = `
if (substrateMode == 3) {
float visibleEtch = metalEtchDepth * metalEtchExposure;
float etchEdge = smoothstep(0.002, 0.045, fwidth(visibleEtch));
vec2 alongLight = vec2(dot(lightDirection, normalize(vWorldTangent)),
dot(lightDirection, normalize(vWorldBitangent)));
vec2 ridgeUv = clamp(artworkUv + alongLight * metalRelief * metalBoundary / cardFaceSize,
vec2(0.0), vec2(1.0));
float ridgeDepth = metalDepth(texture2D(artwork, ridgeUv).rgb) * metalEtchExposure;
float recessShadow = smoothstep(0.02, 0.25, visibleEtch - ridgeDepth)
* min(detail, 1.5) * metalBoundary;
vec3 metalUnderprint = mix(
artworkSample.rgb * vec3(0.72, 0.76, 0.82),
environment,
@@ -334,10 +414,10 @@ const fragmentShader = `
printedInk = mix(printedInk, metalUnderprint, 0.42);
printedInk *= 1.0 - visibleEtch * 0.06;
printedInk += lightColor * etchEdge * 0.025;
printedInk *= 1.0 - recessShadow * 0.22;
}
if (substrateMode == 4) {
float grain = woodGrain(artworkUv);
vec3 woodTint = mix(vec3(0.72, 0.40, 0.18), vec3(1.0, 0.86, 0.65), grain);
vec3 woodTint = mix(vec3(0.72, 0.40, 0.18), vec3(1.0, 0.86, 0.65), woodSurfaceGrain);
printedInk *= mix(vec3(1.0), woodTint, 0.25);
}
if (finishMode == 0) {
@@ -455,6 +535,7 @@ export function createCardMaterial(
normalStrength: { value: 0.14 },
condition: { value: 1.0 },
imperfectionSeed: { value: 81251 },
cardFaceSize: { value: new THREE.Vector2(cardSceneDimensions.width, cardSceneDimensions.height) },
finishMode: { value: 2 },
substrateMode: { value: 0 },
lightMode: { value: 0 },

View File

@@ -173,5 +173,12 @@ export function createFoilWrapper() {
strip.visible = detach < 1
}
deform(0, 0, 0, new THREE.Vector3())
return { root, deform }
function dispose() {
for (const { mesh } of sheets) mesh.geometry.dispose()
stripGeometry.dispose()
frontTexture.dispose()
backMaterial.map!.dispose()
for (const material of [frontMaterial, backMaterial, stripMaterial]) material.dispose()
}
return { root, deform, dispose }
}

View File

@@ -720,6 +720,8 @@ async function preparePack() {
packLoading = true
packError = undefined
updatePackUI()
let preparedPack: PackOpening | undefined
let textures: THREE.Texture[] = []
try {
// Separate texture ownership keeps local artwork, experiments and fixture disposal independent.
const paths = [fixtureAssets.David.artwork, fixtureAssets.David.mask,
@@ -732,12 +734,12 @@ async function preparePack() {
}
throw failure.reason
}
const textures = results.map((result, index) => {
textures = results.map((result, index) => {
if (result.status !== 'fulfilled') throw new Error(`Missing pack asset: ${paths[index]}`)
configureFrontTexture(result.value, index % 2 ? THREE.NoColorSpace : THREE.SRGBColorSpace)
return result.value
})
pack = new PackOpening({
preparedPack = new PackOpening({
canvas,
textures: {
David: { artwork: textures[0], mask: textures[1] },
@@ -749,12 +751,24 @@ async function preparePack() {
lightPosition,
onChange: () => { packUIDirty = true },
})
scene.add(pack.root)
pack.syncLighting(frontMaterial)
pack.setActive(mode === 'Pack')
pack.resize(canvas.clientWidth, canvas.clientHeight)
preparedPack.resize(canvas.clientWidth, canvas.clientHeight)
let preparedEnvironment: THREE.Texture | null
let preparedLightType: LightType
do {
preparedEnvironment = scene.environment
preparedLightType = controls.lightType
preparedPack.syncLighting(frontMaterial)
await preparedPack.prepareGPU(renderer, scene)
} while (scene.environment !== preparedEnvironment || controls.lightType !== preparedLightType)
preparedPack.syncLighting(frontMaterial)
preparedPack.resize(canvas.clientWidth, canvas.clientHeight)
preparedPack.setActive(mode === 'Pack')
scene.add(preparedPack.root)
pack = preparedPack
} catch (error) {
packError = `Pack assets unavailable: ${error instanceof Error ? error.message : String(error)}`
preparedPack?.dispose()
for (const texture of textures) texture.dispose()
packError = `Pack unavailable: ${error instanceof Error ? error.message : String(error)}`
console.error('Could not prepare pack prototype', error)
} finally {
packLoading = false

View File

@@ -186,6 +186,54 @@ export class PackOpening {
}
}
async prepareGPU(renderer: THREE.WebGLRenderer, scene: THREE.Scene) {
if (this.active) throw new Error('Prepare pack GPU resources before activating the pack')
const textures = new Set<THREE.Texture>()
if (scene.environment) textures.add(scene.environment)
this.root.traverse((object) => {
if (!(object instanceof THREE.Mesh)) return
const materials = Array.isArray(object.material) ? object.material : [object.material]
for (const material of materials) {
for (const value of Object.values(material)) {
if (value instanceof THREE.Texture) textures.add(value)
}
if (material instanceof THREE.ShaderMaterial) {
for (const uniform of Object.values(material.uniforms)) {
if (uniform.value instanceof THREE.Texture) textures.add(uniform.value)
}
}
}
})
for (const texture of textures) renderer.initTexture(texture)
// compileAsync visits hidden fronts without drawing them or changing reveal state.
await renderer.compileAsync(this.root, this.camera, scene)
try {
this.cards.forEach((card, index) => {
applyEdgeMaterialControls(card.edge, { ...packContents[index], condition: 1 })
})
await renderer.compileAsync(this.root, this.camera, scene)
} finally {
for (const card of this.cards) applyEdgeMaterialControls(card.edge, { substrate: 'Paper', condition: 1 })
}
}
dispose() {
this.clearPointers()
this.active = false
this.root.visible = false
this.root.removeFromParent()
this.wrapper.dispose()
const geometries = new Set<THREE.BufferGeometry>()
for (const card of this.cards) {
card.object.traverse((object) => {
if (object instanceof THREE.Mesh) geometries.add(object.geometry)
})
card.material.dispose()
card.edge.dispose()
}
for (const geometry of geometries) geometry.dispose()
}
setActive(active: boolean) {
if (!active) this.pause(performance.now())
this.clearPointers()