Updated perf
This commit is contained in:
@@ -67,6 +67,13 @@ unexpectedly enlarge the card. The edge mesh draws only extrusion side walls, no
|
||||
dedicated artwork faces, preventing depth-fighting bands at farther zoom distances.
|
||||
Pack assets are loaded separately on first entry, with retry on failure.
|
||||
|
||||
Wrapper deformation is coalesced to once per rendered frame, including touch dents
|
||||
and tear input. Fixed crease/crimp calculations are cached; unchanged pouch surfaces
|
||||
and ribbons do not recalculate normals/bounds or upload vertex buffers. Hidden
|
||||
wrappers do not deform. Pack UI updates are also coalesced and only write changed
|
||||
values. These optimizations retain the same mesh resolution, deformation formulas,
|
||||
materials, lighting, pixel ratio, and antialiasing.
|
||||
|
||||
There is no sound, haptics, particles, cloth simulation, backend, rewards persistence,
|
||||
pack progress persistence, or timeline/editor. This is a choreography proof, not an
|
||||
final pack-design or mobile performance sign-off. The approved rigid prototype and
|
||||
@@ -116,6 +123,17 @@ Production build:
|
||||
npm run build
|
||||
```
|
||||
|
||||
Pack regression checks (Node 22.15+):
|
||||
|
||||
```sh
|
||||
npm test
|
||||
```
|
||||
|
||||
These check exact pre-optimization wrapper geometry snapshots, surface update
|
||||
counts, per-frame input batching, pause/resume, pinch handoff, restart, reduced
|
||||
motion, and ordered reveals. They are CPU/state checks, not a real-device GPU or
|
||||
touch-latency benchmark; mobile performance still needs on-device validation.
|
||||
|
||||
The asset-generation script uses cross-platform Node APIs and works on Windows and Linux. The retained `export:blender` script is an optional reference utility and is not part of the application pipeline.
|
||||
|
||||
## Baseline
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"scripts": {
|
||||
"assets": "node scripts/generate-reference-assets.mjs",
|
||||
"export:blender": "node scripts/export-card-mesh.mjs",
|
||||
"test": "node --test scripts/*.test.mjs",
|
||||
"predev": "npm run assets",
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"prebuild": "npm run assets",
|
||||
|
||||
233
spikes/card-harness/scripts/pack-performance.test.mjs
Normal file
233
spikes/card-harness/scripts/pack-performance.test.mjs
Normal file
@@ -0,0 +1,233 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import * as THREE from 'three'
|
||||
import { installDOM, TestCanvas, geometryHash, wrapperCases } from './test-support.mjs'
|
||||
|
||||
const { createFoilWrapper } = await import('../src/foilWrapper.ts')
|
||||
const { PackOpening } = await import('../src/packOpening.ts')
|
||||
|
||||
// Captured from the pre-optimization implementation, including normals, UVs and bounds.
|
||||
const originalHashes = [
|
||||
'f20984ff506a2b6ac557e30592b4d3b990efdd5e9f335a2655fe78567e240d1a',
|
||||
'2ae0cf1d2ba9fcc29dde5cd6c5cca0862e22466ed5a8c4257a3d0d0075b9d500',
|
||||
'f8c912718e192139b6d33843c2c5527e765372f339897033c864909929b59bbe',
|
||||
'1f70db18f24f8a629a0765e21cb1d73f377ffb816e2e7893edaabe9616053441',
|
||||
'64e019c59fa6fe120ac044a01b2ba852ce09e86cd4abe6d286bb18d456bd8700',
|
||||
'140e2ad4fdc339d671a8b19da32c89b74c98e785b05250f4a4c491edf23a460a',
|
||||
'cacbbd787ff9f47acddf0227d31fe8051adf06d6e401a0f53a69a7d81c3c12fc',
|
||||
'7a76c0eeea7cf22a3f6d888c43df76a2dcdeaad418d91c9cee5e5102a09ebf49',
|
||||
'e5625218a4d89f58bb8a935d30fa6aee80613b41868bad334079fca6da33e397',
|
||||
'f20984ff506a2b6ac557e30592b4d3b990efdd5e9f335a2655fe78567e240d1a',
|
||||
'a6f746d8d729cf77068a05a24269024cd30c1a807828b907c559c140645a3b86',
|
||||
'91fd70b90a7a40976ccfe9ad6727af5f12db6e8f20ac414503f5fe10aaee4d8b',
|
||||
]
|
||||
|
||||
test('wrapper geometry exactly matches the original across tear, dent, mouth and restart poses', () => {
|
||||
installDOM()
|
||||
const wrapper = createFoilWrapper()
|
||||
wrapperCases.forEach(([tear, detach, mouth, touch, exit], index) => {
|
||||
wrapper.deform(tear, detach, mouth, new THREE.Vector3(...touch), exit)
|
||||
assert.equal(geometryHash(wrapper), originalHashes[index], `pose ${index}`)
|
||||
})
|
||||
})
|
||||
|
||||
test('only changed surfaces recompute normals, bounds and upload buffers', (t) => {
|
||||
installDOM()
|
||||
const wrapper = createFoilWrapper()
|
||||
const meshes = wrapper.root.children
|
||||
const normalSpies = meshes.map((mesh) => t.mock.method(mesh.geometry, 'computeVertexNormals'))
|
||||
const boundsSpies = meshes.map((mesh) => t.mock.method(mesh.geometry, 'computeBoundingSphere'))
|
||||
const versions = () => meshes.map((mesh) => [
|
||||
mesh.geometry.getAttribute('position').version, mesh.geometry.getAttribute('normal').version,
|
||||
])
|
||||
const before = versions()
|
||||
const touch = new THREE.Vector3()
|
||||
wrapper.deform(0, 0, 0, touch)
|
||||
assert.deepEqual(versions(), before)
|
||||
assert.deepEqual(normalSpies.map((spy) => spy.mock.callCount()), [0, 0, 0])
|
||||
|
||||
wrapper.deform(0.5, 0, 0, touch)
|
||||
assert.deepEqual(normalSpies.map((spy) => spy.mock.callCount()), [0, 0, 1])
|
||||
assert.deepEqual(versions().slice(0, 2), before.slice(0, 2))
|
||||
wrapper.deform(0.5, 0, 0, touch.set(0.4, 1.7, 0.5))
|
||||
assert.deepEqual(normalSpies.map((spy) => spy.mock.callCount()), [1, 0, 1])
|
||||
wrapper.deform(0.5, 0, 0.5, touch)
|
||||
assert.deepEqual(normalSpies.map((spy) => spy.mock.callCount()), [2, 1, 1])
|
||||
assert.deepEqual(boundsSpies.map((spy) => spy.mock.callCount()), [2, 1, 1])
|
||||
})
|
||||
|
||||
function setupPack(t, reducedMotion = false) {
|
||||
installDOM(reducedMotion)
|
||||
let now = 1000
|
||||
t.mock.method(performance, 'now', () => now)
|
||||
const canvas = new TestCanvas()
|
||||
const pack = new PackOpening({
|
||||
canvas,
|
||||
textures: {
|
||||
David: { artwork: new THREE.Texture(), mask: new THREE.Texture() },
|
||||
Timothy: { artwork: new THREE.Texture(), mask: new THREE.Texture() },
|
||||
},
|
||||
backMaterial: new THREE.MeshPhysicalMaterial(),
|
||||
normalMap: new THREE.Texture(),
|
||||
environment: new THREE.CubeTexture(),
|
||||
lightPosition: new THREE.Vector3(2.2, 2.5, 4.4),
|
||||
onChange() {},
|
||||
})
|
||||
pack.resize(canvas.width, canvas.height)
|
||||
pack.setActive(true)
|
||||
pack.tick(now)
|
||||
const deform = t.mock.method(pack.wrapper, 'deform')
|
||||
return {
|
||||
pack, canvas, deform,
|
||||
step(ms = 16) { now += ms; pack.tick(now) },
|
||||
}
|
||||
}
|
||||
|
||||
function pointer(id, x, y) {
|
||||
return { pointerId: id, clientX: x, clientY: y, button: 0 }
|
||||
}
|
||||
|
||||
test('a burst of tear events and the touch dent deform at most once per rendered frame', (t) => {
|
||||
const { pack, deform, step } = setupPack(t)
|
||||
const seam = pack.seamScreenBounds()
|
||||
const start = pointer(1, seam.left + 10, seam.y)
|
||||
pack.pointerDown(start)
|
||||
for (let offset = 10; offset <= 100; offset += 10) {
|
||||
pack.pointerMove(pointer(1, start.clientX + offset, start.clientY))
|
||||
}
|
||||
const progress = pack.tearProgress
|
||||
assert.ok(progress > 0 && progress < 1)
|
||||
assert.equal(deform.mock.callCount(), 0)
|
||||
step()
|
||||
assert.equal(deform.mock.callCount(), 1)
|
||||
assert.equal(deform.mock.calls[0].arguments[0], progress)
|
||||
assert.ok(deform.mock.calls[0].arguments[3].z > 0)
|
||||
pack.pointerMove(pointer(1, start.clientX + 20, start.clientY))
|
||||
assert.equal(pack.tearProgress, progress, 'pulling backward must not reseal')
|
||||
pack.pointerUp(pointer(1, start.clientX + 20, start.clientY), true)
|
||||
step()
|
||||
assert.equal(pack.tearProgress, progress)
|
||||
assert.equal(pack.paused, true)
|
||||
pack.primary()
|
||||
step(3200)
|
||||
assert.equal(pack.state, 'stackReady')
|
||||
})
|
||||
|
||||
test('body dragging batches dent changes without updating the back or ribbon', (t) => {
|
||||
const { pack, deform, step } = setupPack(t)
|
||||
pack.pointerDown(pointer(1, 195, 368))
|
||||
for (let x = 200; x <= 220; x += 5) pack.pointerMove(pointer(1, x, 368))
|
||||
assert.equal(deform.mock.callCount(), 0)
|
||||
const [front, back, strip] = pack.wrapper.root.children
|
||||
const backVersion = back.geometry.getAttribute('position').version
|
||||
const stripVersion = strip.geometry.getAttribute('position').version
|
||||
const frontVersion = front.geometry.getAttribute('position').version
|
||||
step()
|
||||
assert.equal(deform.mock.callCount(), 1)
|
||||
assert.ok(front.geometry.getAttribute('position').version > frontVersion)
|
||||
assert.equal(back.geometry.getAttribute('position').version, backVersion)
|
||||
assert.equal(strip.geometry.getAttribute('position').version, stripVersion)
|
||||
})
|
||||
|
||||
test('interruption and mode changes preserve progress without deforming in pointer handlers', (t) => {
|
||||
const { pack, deform, step } = setupPack(t)
|
||||
pack.primary()
|
||||
step(600)
|
||||
const calls = deform.mock.callCount()
|
||||
const versions = pack.wrapper.root.children.map((mesh) => mesh.geometry.getAttribute('position').version)
|
||||
pack.pointerDown(pointer(1, 0, 0))
|
||||
const progress = pack.tearProgress
|
||||
assert.equal(deform.mock.callCount(), calls)
|
||||
assert.equal(pack.paused, true)
|
||||
pack.pointerUp(pointer(1, 0, 0))
|
||||
step(600)
|
||||
assert.equal(pack.tearProgress, progress)
|
||||
assert.deepEqual(
|
||||
pack.wrapper.root.children.map((mesh) => mesh.geometry.getAttribute('position').version), versions,
|
||||
)
|
||||
const pausedCalls = deform.mock.callCount()
|
||||
step()
|
||||
assert.equal(deform.mock.callCount(), pausedCalls)
|
||||
pack.setActive(false)
|
||||
step(500)
|
||||
pack.setActive(true)
|
||||
assert.equal(pack.tearProgress, progress)
|
||||
assert.equal(pack.view.action, 'Continue')
|
||||
pack.primary()
|
||||
step(3200)
|
||||
assert.equal(pack.state, 'stackReady')
|
||||
})
|
||||
|
||||
test('a second finger cancels seam dragging for pinch without losing the partial tear', (t) => {
|
||||
const { pack, canvas, step } = setupPack(t)
|
||||
const seam = pack.seamScreenBounds()
|
||||
const start = pointer(1, seam.left + 10, seam.y)
|
||||
pack.pointerDown(start)
|
||||
pack.pointerMove(pointer(1, start.clientX + 70, seam.y))
|
||||
step()
|
||||
const progress = pack.tearProgress
|
||||
pack.pointerDown(pointer(2, start.clientX + 170, seam.y))
|
||||
pack.pointerMove(pointer(2, start.clientX + 200, seam.y))
|
||||
assert.equal(pack.tearProgress, progress)
|
||||
assert.ok(pack.zoom < 1)
|
||||
pack.restart()
|
||||
step()
|
||||
assert.equal(canvas.captures.size, 0)
|
||||
assert.equal(pack.tearProgress, 0)
|
||||
assert.equal(geometryHash(pack.wrapper), originalHashes[0])
|
||||
})
|
||||
|
||||
test('full-pull release continues opening and hidden wrappers do no geometry work', (t) => {
|
||||
const { pack, deform, step } = setupPack(t)
|
||||
const seam = pack.seamScreenBounds()
|
||||
pack.pointerDown(pointer(1, seam.left + 10, seam.y))
|
||||
pack.pointerMove(pointer(1, seam.right + 10, seam.y))
|
||||
pack.pointerUp(pointer(1, seam.right + 10, seam.y))
|
||||
assert.equal(pack.tearProgress, 1)
|
||||
step(3200)
|
||||
assert.equal(pack.state, 'stackReady')
|
||||
assert.equal(pack.wrapper.root.visible, false)
|
||||
assert.equal(deform.mock.callCount(), 0)
|
||||
pack.restart()
|
||||
step()
|
||||
assert.equal(pack.wrapper.root.visible, true)
|
||||
assert.equal(geometryHash(pack.wrapper), originalHashes[0])
|
||||
})
|
||||
|
||||
test('skip retains ordered lift/reveal states and emits each reveal and completion once', (t) => {
|
||||
const { pack, canvas, step } = setupPack(t)
|
||||
const reveals = []
|
||||
let completions = 0
|
||||
canvas.addEventListener('packreveal', (event) => reveals.push(event.detail.index))
|
||||
canvas.addEventListener('packcomplete', () => completions++)
|
||||
pack.skip()
|
||||
step()
|
||||
for (let index = 1; index <= 3; index++) {
|
||||
assert.equal(pack.state, 'stackReady')
|
||||
assert.equal(pack.cards.some((card) => card.front.visible), false)
|
||||
pack.skip()
|
||||
assert.equal(pack.state, 'lifted')
|
||||
assert.equal(pack.cards.some((card) => card.front.visible), false)
|
||||
pack.skip()
|
||||
assert.equal(pack.state, 'inspecting')
|
||||
step()
|
||||
pack.skip()
|
||||
}
|
||||
assert.equal(pack.state, 'complete')
|
||||
pack.skip()
|
||||
assert.deepEqual(reveals, [1, 2, 3])
|
||||
assert.equal(completions, 1)
|
||||
pack.restart()
|
||||
step()
|
||||
assert.equal(pack.state, 'sealed')
|
||||
assert.equal(geometryHash(pack.wrapper), originalHashes[0])
|
||||
})
|
||||
|
||||
test('reduced-motion opening keeps the existing 70 ms transition duration', (t) => {
|
||||
const { pack, step } = setupPack(t, true)
|
||||
pack.primary()
|
||||
step(69)
|
||||
assert.equal(pack.state, 'opening')
|
||||
step(1)
|
||||
assert.equal(pack.state, 'stackReady')
|
||||
})
|
||||
74
spikes/card-harness/scripts/test-support.mjs
Normal file
74
spikes/card-harness/scripts/test-support.mjs
Normal file
@@ -0,0 +1,74 @@
|
||||
import { registerHooks } from 'node:module'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import ts from 'typescript'
|
||||
import { createHash } from 'node:crypto'
|
||||
|
||||
registerHooks({
|
||||
resolve(specifier, context, nextResolve) {
|
||||
if (specifier.startsWith('./') && context.parentURL?.endsWith('.ts') && !specifier.endsWith('.ts')) {
|
||||
return nextResolve(`${specifier}.ts`, context)
|
||||
}
|
||||
return nextResolve(specifier, context)
|
||||
},
|
||||
load(url, context, nextLoad) {
|
||||
if (!url.endsWith('.ts')) return nextLoad(url, context)
|
||||
const source = ts.transpileModule(readFileSync(new URL(url), 'utf8'), {
|
||||
compilerOptions: { target: ts.ScriptTarget.ES2023, module: ts.ModuleKind.ESNext },
|
||||
}).outputText
|
||||
return { format: 'module', source, shortCircuit: true }
|
||||
},
|
||||
})
|
||||
|
||||
export class TestCanvas extends EventTarget {
|
||||
width = 390
|
||||
height = 736
|
||||
captures = new Set()
|
||||
getContext() {
|
||||
return {
|
||||
fillRect() {}, strokeRect() {}, fillText() {}, save() {}, restore() {},
|
||||
translate() {}, rotate() {}, createLinearGradient() { return { addColorStop() {} } },
|
||||
}
|
||||
}
|
||||
getBoundingClientRect() { return { left: 0, top: 0, width: this.width, height: this.height } }
|
||||
setPointerCapture(id) { this.captures.add(id) }
|
||||
hasPointerCapture(id) { return this.captures.has(id) }
|
||||
releasePointerCapture(id) { this.captures.delete(id) }
|
||||
}
|
||||
|
||||
export function installDOM(reducedMotion = false) {
|
||||
globalThis.document = {
|
||||
createElement(name) {
|
||||
if (name !== 'canvas') throw new Error(`Unexpected test element: ${name}`)
|
||||
return new TestCanvas()
|
||||
},
|
||||
}
|
||||
globalThis.window = { matchMedia: () => ({ matches: reducedMotion }) }
|
||||
}
|
||||
|
||||
export function geometryHash(wrapper) {
|
||||
const hash = createHash('sha256')
|
||||
for (const mesh of wrapper.root.children) {
|
||||
for (const name of ['position', 'normal', 'uv']) {
|
||||
const array = mesh.geometry.getAttribute(name).array
|
||||
hash.update(Buffer.from(array.buffer, array.byteOffset, array.byteLength))
|
||||
}
|
||||
hash.update(JSON.stringify(mesh.geometry.boundingSphere))
|
||||
hash.update(String(mesh.visible))
|
||||
}
|
||||
return hash.digest('hex')
|
||||
}
|
||||
|
||||
export const wrapperCases = [
|
||||
[0, 0, 0, [0, 0, 0], 4.8],
|
||||
[0.25, 0, 0, [-1.2, 1.7, 0.6], 4.8],
|
||||
[0.89, 0, 0, [0.2, 1.7, 1], 4.8],
|
||||
[1, 0, 0, [1.6, 1.7, 0.8], 4.8],
|
||||
[1, 0.4, 0, [1.6, 1.7, 0.3], 6.3],
|
||||
[1, 0.99, 0.05, [1.6, 1.7, 0.1], 6.3],
|
||||
[1, 1, 0.5, [0, 0, 0], 6.3],
|
||||
[1, 1, 1, [0, 0, 0], 6.3],
|
||||
[1, 1, 1, [0, 0, 0], 12.7],
|
||||
[0, 0, 0, [0, 0, 0], 4.8],
|
||||
[0, 0, 0, [0.3, -1.2, 1], 4.8],
|
||||
[0, 0, 0, [-1.68, -2.24, 0.001], 4.8],
|
||||
]
|
||||
@@ -89,52 +89,83 @@ export function createFoilWrapper() {
|
||||
return 0.011 * Math.sin(u * Math.PI * 96) + 0.005 * Math.sin(u * 173)
|
||||
}
|
||||
|
||||
const sheetUV = sheets[0].mesh.geometry.getAttribute('uv')
|
||||
const sheetSamples = Array.from({ length: sheetUV.count }, (_, i) => {
|
||||
const u = sheetUV.getX(i)
|
||||
const y = bottom + sheetUV.getY(i) * (top - bottom)
|
||||
const x = (u * 2 - 1) * halfWidth
|
||||
const across = u === 0 || u === 1 ? 0 : Math.pow(Math.sin(u * Math.PI), 0.24)
|
||||
const lower = smooth(y, bottom + 0.14, bottom + 0.47)
|
||||
const neck = 1 - smooth(y, 1.65, seam)
|
||||
const envelope = across * lower
|
||||
const diagonal = Math.sin(x * 13 + y * 7) * Math.sin(y * 3.1 - x * 2)
|
||||
const folds = (0.009 * diagonal + 0.003 * Math.sin(x * 28 - y * 5))
|
||||
* envelope * (0.35 + 0.65 * Math.pow(Math.abs(x / halfWidth), 1.5))
|
||||
const crimp = 0.013 * (1 + 0.7 * Math.sin(x * 36))
|
||||
* (1 - lower) * smooth(y, bottom, bottom + 0.1) * across
|
||||
return { x, y, across, neck, envelope, folds, crimp,
|
||||
opening: smooth(y, 0.6, seam), lip: smooth(y, 1, seam),
|
||||
edgeY: y + tornEdge(u) * smooth(y, 1.85, seam) }
|
||||
})
|
||||
const stripSamples = Array.from({ length: stripUV.count }, (_, i) => {
|
||||
const u = stripUV.getX(i)
|
||||
const v = (bottom + stripUV.getY(i) * (top - bottom) - seam) / (top - seam)
|
||||
return { u,
|
||||
y: seam + v * (top - seam) + tornEdge(u) * (1 - v),
|
||||
z: midDepth + 0.012 * Math.sin((u * 2 - 1) * halfWidth * 36) * Math.sin(v * Math.PI) }
|
||||
})
|
||||
for (const geometry of [...sheets.map(({ mesh }) => mesh.geometry), stripGeometry]) {
|
||||
for (const name of ['position', 'normal']) {
|
||||
const attribute = geometry.getAttribute(name)
|
||||
const buffer = attribute instanceof THREE.InterleavedBufferAttribute ? attribute.data : attribute
|
||||
buffer.setUsage(THREE.DynamicDrawUsage)
|
||||
}
|
||||
}
|
||||
let lastMouth: number | undefined
|
||||
let lastTear: number | undefined
|
||||
let lastDetach: number | undefined
|
||||
let lastStripExit: number | undefined
|
||||
const lastTouch = new THREE.Vector3()
|
||||
|
||||
function deform(tear: number, detach: number, mouth: number, touch: THREE.Vector3, stripExit = 4.8) {
|
||||
for (const { mesh, side } of sheets) {
|
||||
if (mouth === lastMouth && (side === -1 || touch.equals(lastTouch))) continue
|
||||
const geometry = mesh.geometry
|
||||
const positions = geometry.getAttribute('position')
|
||||
const uv = geometry.getAttribute('uv')
|
||||
for (let i = 0; i < positions.count; i++) {
|
||||
const u = uv.getX(i)
|
||||
const y = bottom + uv.getY(i) * (top - bottom)
|
||||
const x = (u * 2 - 1) * halfWidth
|
||||
const across = u === 0 || u === 1 ? 0 : Math.pow(Math.sin(u * Math.PI), 0.24)
|
||||
const lower = smooth(y, bottom + 0.14, bottom + 0.47)
|
||||
const neck = 1 - smooth(y, 1.65, seam)
|
||||
const opening = mouth * smooth(y, 0.6, seam)
|
||||
const envelope = across * lower
|
||||
const diagonal = Math.sin(x * 13 + y * 7) * Math.sin(y * 3.1 - x * 2)
|
||||
const folds = (0.009 * diagonal + 0.003 * Math.sin(x * 28 - y * 5))
|
||||
* envelope * (0.35 + 0.65 * Math.pow(Math.abs(x / halfWidth), 1.5))
|
||||
const crimp = 0.013 * (1 + 0.7 * Math.sin(x * 36))
|
||||
* (1 - lower) * smooth(y, bottom, bottom + 0.1) * across
|
||||
const sample = sheetSamples[i]
|
||||
const { x, y, across, neck, envelope, folds, crimp } = sample
|
||||
const opening = mouth * sample.opening
|
||||
const dent = side === 1 ? -0.045 * touch.z
|
||||
* Math.exp(-((x - touch.x) ** 2 + (y - touch.y) ** 2) * 5) * envelope * neck : 0
|
||||
const depth = 0.37 * neck + 0.64 * opening
|
||||
const lip = mouth * (side === 1 ? -0.28 : 0.08) * across * smooth(y, 1, seam)
|
||||
positions.setXYZ(i, x, y + tornEdge(u) * smooth(y, 1.85, seam) + lip,
|
||||
const lip = mouth * (side === 1 ? -0.28 : 0.08) * across * sample.lip
|
||||
positions.setXYZ(i, x, sample.edgeY + lip,
|
||||
midDepth + side * (depth * envelope + folds * neck + crimp) + dent)
|
||||
}
|
||||
positions.needsUpdate = true
|
||||
geometry.computeVertexNormals()
|
||||
geometry.computeBoundingSphere()
|
||||
}
|
||||
lastMouth = mouth
|
||||
lastTouch.copy(touch)
|
||||
if (tear === lastTear && detach === lastDetach && (detach === 0 || stripExit === lastStripExit)) return
|
||||
lastTear = tear
|
||||
lastDetach = detach
|
||||
lastStripExit = stripExit
|
||||
const positions = stripGeometry.getAttribute('position')
|
||||
const curvature = 0.55 + detach * 0.5
|
||||
for (let i = 0; i < positions.count; i++) {
|
||||
const u = stripUV.getX(i)
|
||||
const v = (bottom + stripUV.getY(i) * (top - bottom) - seam) / (top - seam)
|
||||
const { u, y, z } = stripSamples[i]
|
||||
const length = Math.max(0, tear - u) * halfWidth * 2
|
||||
const curvature = 0.55 + detach * 0.5
|
||||
const angle = length * curvature
|
||||
const anchor = (Math.max(tear, u) * 2 - 1) * halfWidth
|
||||
const release = smooth(length, 0, 0.3)
|
||||
// The released end rolls up around the moving tear front; the attached end stays sealed.
|
||||
positions.setXYZ(i,
|
||||
anchor - Math.sin(angle) / curvature + detach * stripExit,
|
||||
seam + v * (top - seam) + tornEdge(u) * (1 - v)
|
||||
+ 0.36 * (1 - Math.cos(angle)) / curvature + release * 0.035 + detach * 0.4,
|
||||
midDepth + 0.012 * Math.sin((u * 2 - 1) * halfWidth * 36) * Math.sin(v * Math.PI)
|
||||
- 0.85 * (1 - Math.cos(angle)) / curvature - detach * 0.65)
|
||||
y + 0.36 * (1 - Math.cos(angle)) / curvature + release * 0.035 + detach * 0.4,
|
||||
z - 0.85 * (1 - Math.cos(angle)) / curvature - detach * 0.65)
|
||||
}
|
||||
positions.needsUpdate = true
|
||||
stripGeometry.computeVertexNormals()
|
||||
|
||||
@@ -565,6 +565,7 @@ let lastPerformanceUpdate = performance.now()
|
||||
let lastFrameTime: number | undefined
|
||||
let pack: PackOpening | undefined
|
||||
let packLoading = false
|
||||
let packUIDirty = false
|
||||
let packError: string | undefined
|
||||
|
||||
function updateMaterial() {
|
||||
@@ -684,21 +685,28 @@ function updateStatus() {
|
||||
}
|
||||
|
||||
function updatePackUI() {
|
||||
packUIDirty = false
|
||||
if (mode !== 'Pack') return
|
||||
const view = pack?.view
|
||||
packControlsElement.dataset.state = pack?.state ?? (packError ? 'error' : 'loading')
|
||||
packControlsElement.dataset.paused = String(pack?.paused ?? false)
|
||||
packTearProgress.hidden = !!pack && pack.state !== 'sealed' && pack.state !== 'opening'
|
||||
packTearProgress.value = pack?.tearProgress ?? 0
|
||||
packPrimaryButton.textContent = view?.action ?? (packError ? 'Retry loading' : 'Loading…')
|
||||
packPrimaryButton.disabled = packLoading || pack?.state === 'complete'
|
||||
packRestartButton.disabled = !pack
|
||||
packSkipButton.disabled = !pack || pack.state === 'complete'
|
||||
packSkipButton.title = 'Finish this motion or move to the next resting state'
|
||||
const state = pack?.state ?? (packError ? 'error' : 'loading')
|
||||
const paused = String(pack?.paused ?? false)
|
||||
if (packControlsElement.dataset.state !== state) packControlsElement.dataset.state = state
|
||||
if (packControlsElement.dataset.paused !== paused) packControlsElement.dataset.paused = paused
|
||||
setIfChanged(packTearProgress, 'hidden', !!pack && pack.state !== 'sealed' && pack.state !== 'opening')
|
||||
setIfChanged(packTearProgress, 'value', pack?.tearProgress ?? 0)
|
||||
setIfChanged(packPrimaryButton, 'textContent', view?.action ?? (packError ? 'Retry loading' : 'Loading…'))
|
||||
setIfChanged(packPrimaryButton, 'disabled', packLoading || pack?.state === 'complete')
|
||||
setIfChanged(packRestartButton, 'disabled', !pack)
|
||||
setIfChanged(packSkipButton, 'disabled', !pack || pack.state === 'complete')
|
||||
setIfChanged(packSkipButton, 'title', 'Finish this motion or move to the next resting state')
|
||||
const status = view?.status ?? packError ?? 'Preparing the prototype pack…'
|
||||
if (packStatusElement.textContent !== status) packStatusElement.textContent = status
|
||||
statusElement.textContent = 'Foil tear proof · 3 authored cards · no random rewards'
|
||||
hintElement.textContent = view?.hint ?? 'Local reference artwork · approved runtime card materials'
|
||||
setIfChanged(packStatusElement, 'textContent', status)
|
||||
setIfChanged(statusElement, 'textContent', 'Foil tear proof · 3 authored cards · no random rewards')
|
||||
setIfChanged(hintElement, 'textContent', view?.hint ?? 'Local reference artwork · approved runtime card materials')
|
||||
}
|
||||
|
||||
function setIfChanged<T, K extends keyof T>(target: T, key: K, value: T[K]) {
|
||||
if (target[key] !== value) target[key] = value
|
||||
}
|
||||
|
||||
async function preparePack() {
|
||||
@@ -739,7 +747,7 @@ async function preparePack() {
|
||||
normalMap,
|
||||
environment: activeEnvironment,
|
||||
lightPosition,
|
||||
onChange: updatePackUI,
|
||||
onChange: () => { packUIDirty = true },
|
||||
})
|
||||
scene.add(pack.root)
|
||||
pack.syncLighting(frontMaterial)
|
||||
@@ -1462,6 +1470,7 @@ function animate(frameTime: number) {
|
||||
}
|
||||
|
||||
if (mode === 'Pack') pack?.tick(frameTime)
|
||||
if (packUIDirty) updatePackUI()
|
||||
renderer.render(scene, mode === 'Pack' && pack ? pack.camera : camera)
|
||||
}
|
||||
|
||||
|
||||
@@ -112,6 +112,8 @@ export class PackOpening {
|
||||
private readonly touch = new THREE.Vector3()
|
||||
private touchTarget = 0
|
||||
private lastTick: number | undefined
|
||||
private wrapperDirty = false
|
||||
private wrapperStripExit = 4.8
|
||||
private readonly pointers = new Map<number, THREE.Vector2>()
|
||||
private tap: { id: number; x: number; y: number; at: number } | undefined
|
||||
private pinchDistance: number | undefined
|
||||
@@ -234,7 +236,7 @@ export class PackOpening {
|
||||
this.updateCamera()
|
||||
this.wrapper.root.visible = true
|
||||
setPose(this.wrapper.root, pose(0, 0, 0))
|
||||
this.wrapper.deform(0, 0, 0, this.touch)
|
||||
this.invalidateWrapper()
|
||||
this.cards.forEach((card, index) => {
|
||||
card.object.visible = true
|
||||
card.front.visible = false
|
||||
@@ -244,17 +246,15 @@ export class PackOpening {
|
||||
this.setState('sealed')
|
||||
}
|
||||
|
||||
private shapeWrapper() {
|
||||
const stripExit = (this.camera.position.z + 4)
|
||||
private invalidateWrapper() {
|
||||
this.wrapperDirty = true
|
||||
this.wrapperStripExit = (this.camera.position.z + 4)
|
||||
* Math.tan(THREE.MathUtils.degToRad(this.camera.fov / 2)) * this.camera.aspect + 2
|
||||
this.wrapper.deform(this.tearProgress,
|
||||
THREE.MathUtils.smoothstep(this.openingProgress, 0.38, 0.52),
|
||||
THREE.MathUtils.smoothstep(this.openingProgress, 0.5, 0.62), this.touch, stripExit)
|
||||
}
|
||||
|
||||
private setOpeningProgress(value: number) {
|
||||
this.openingProgress = value
|
||||
this.shapeWrapper()
|
||||
this.invalidateWrapper()
|
||||
const smooth = THREE.MathUtils.smoothstep
|
||||
const settling = smooth(value, 0.87, 1)
|
||||
const framing = smooth(value, 0.52, 0.76) * (1 - settling)
|
||||
@@ -397,13 +397,23 @@ export class PackOpening {
|
||||
}
|
||||
|
||||
tick(now: number) {
|
||||
this.updateMotion(now)
|
||||
if (this.active && this.wrapperDirty && this.wrapper.root.visible) {
|
||||
this.wrapper.deform(this.tearProgress,
|
||||
THREE.MathUtils.smoothstep(this.openingProgress, 0.38, 0.52),
|
||||
THREE.MathUtils.smoothstep(this.openingProgress, 0.5, 0.62), this.touch, this.wrapperStripExit)
|
||||
this.wrapperDirty = false
|
||||
}
|
||||
}
|
||||
|
||||
private updateMotion(now: number) {
|
||||
const delta = Math.min(0.05, Math.max(0, (now - (this.lastTick ?? now)) / 1000))
|
||||
this.lastTick = now
|
||||
if (!this.active) return
|
||||
if (Math.abs(this.touch.z - this.touchTarget) > 0.001) {
|
||||
this.touch.z = this.reducedMotion.matches ? this.touchTarget
|
||||
: THREE.MathUtils.lerp(this.touch.z, this.touchTarget, 1 - Math.exp(-delta * 18))
|
||||
this.shapeWrapper()
|
||||
this.invalidateWrapper()
|
||||
}
|
||||
const motion = this.motion
|
||||
if (!motion || this.paused) return
|
||||
@@ -423,7 +433,7 @@ export class PackOpening {
|
||||
|
||||
private pause(now: number) {
|
||||
const interrupted = this.motion !== undefined
|
||||
this.tick(now)
|
||||
this.updateMotion(now)
|
||||
if (this.motion && !this.paused) {
|
||||
this.motion.elapsed += now - this.motion.startedAt
|
||||
this.paused = true
|
||||
@@ -493,7 +503,7 @@ export class PackOpening {
|
||||
if (local) {
|
||||
this.touch.x = THREE.MathUtils.clamp(local.x, -1.68, 1.68)
|
||||
this.touch.y = THREE.MathUtils.clamp(local.y, -2.24, 1.7)
|
||||
this.shapeWrapper()
|
||||
this.invalidateWrapper()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user