Updated perf

This commit is contained in:
2026-09-09 18:01:25 -07:00
parent ad41f0e140
commit 2cfa0ca690
11 changed files with 485 additions and 46 deletions

BIN
spikes/card-back.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

View File

@@ -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

View File

@@ -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",

View 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')
})

View 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],
]

View File

@@ -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()

View File

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

View File

@@ -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()
}
}
}

BIN
spikes/common.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 MiB

BIN
spikes/legendary.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 MiB