diff --git a/src/components/CalibrationPanel.vue b/src/components/CalibrationPanel.vue index 4cc62f8..d14f3a1 100644 --- a/src/components/CalibrationPanel.vue +++ b/src/components/CalibrationPanel.vue @@ -157,7 +157,7 @@ function formatValue(v: number): string {
Backlight
- {{ formatValue(store.config.lights.backlightIntensity) }} @@ -166,12 +166,48 @@ function formatValue(v: number): string {
Spotlight
- {{ formatValue(store.config.lights.spotlightIntensity) }}
+
+ Spot X +
+ + {{ formatValue(store.config.lights.spotlightX) }} +
+
+
+ Spot Y +
+ + {{ formatValue(store.config.lights.spotlightY) }} +
+
+
+ Spot angle +
+ + {{ formatValue(store.config.lights.spotlightAngle) }} +
+
+
+ Spot soft +
+ + {{ formatValue(store.config.lights.spotlightPenumbra) }} +
+
diff --git a/src/composables/useThreeScene.ts b/src/composables/useThreeScene.ts index bc0081d..b32c0ef 100644 --- a/src/composables/useThreeScene.ts +++ b/src/composables/useThreeScene.ts @@ -44,6 +44,8 @@ export function useThreeScene(containerRef: Ref) { let camera: PerspectiveCamera | null = null let animationId: number | null = null let lastTime = performance.now() * 0.001 + let dimStartTime = 0 + let wasDimmed = false // Card state const cardMeshes = shallowRef([]) @@ -406,13 +408,8 @@ export function useThreeScene(containerRef: Ref) { // Update off-axis camera updateOffAxisCamera(store.eyePos.x, store.eyePos.y, store.eyePos.z) - // Update head-tracked spotlight position + // Spotlight (fixed position — intensity updated below) const spotlight = scene.getObjectByName('headSpotlight') as SpotLight | undefined - if (spotlight) { - spotlight.position.set(store.eyePos.x * 0.8, store.eyePos.y * 0.8, store.eyePos.z * 0.6) - spotlight.target.position.set(0, 0, -store.dimensions.boxD * 0.5) - spotlight.target.updateMatrixWorld() - } // Update tilt springs (gyroscope or mouse) const tilt = gyroscope.isActive.value ? gyroscope : mouseTilt @@ -533,10 +530,58 @@ export function useThreeScene(containerRef: Ref) { back.intensity += (targetB - back.intensity) * dimRate } - // Update spotlight intensity from config + // Track dim transition for candle stagger timing + if (store.isDimmed && !wasDimmed) dimStartTime = time + wasDimmed = store.isDimmed + + // Animate candle lights + flames (staggered on, together off, with flicker) + for (let i = 0; i < 5; i++) { + const candle = scene.getObjectByName(`candle${i}`) as PointLight | undefined + const flame = scene.getObjectByName(`candleFlame${i}`) as Mesh | undefined + if (!candle) continue + if (store.isDimmed) { + const elapsed = time - dimStartTime + const delay = i * 0.3 + const baseTarget = elapsed > delay ? 0.8 : 0 + const flicker = baseTarget * (0.9 + 0.1 * Math.sin(time * (3 + i * 0.7))) + candle.intensity += (flicker - candle.intensity) * dimRate + // Flame visibility + wobble + if (flame) { + const targetOpacity = baseTarget > 0 ? 0.9 : 0 + const mat = flame.material as { opacity: number } + mat.opacity += (targetOpacity - mat.opacity) * dimRate + const scaleFlicker = 0.85 + 0.15 * Math.sin(time * (4 + i * 1.1)) + flame.scale.set(scaleFlicker, 0.9 + 0.2 * Math.sin(time * (3.5 + i * 0.9)), scaleFlicker) + } + } else { + candle.intensity += (0 - candle.intensity) * dimRate + if (flame) { + const mat = flame.material as { opacity: number } + mat.opacity += (0 - mat.opacity) * dimRate + } + } + } + + // Update spotlight from config if (spotlight) { - spotlight.intensity += - (store.config.lights.spotlightIntensity - spotlight.intensity) * dimRate + const targetSpot = store.isDimmed ? 0 : store.config.lights.spotlightIntensity + spotlight.intensity += (targetSpot - spotlight.intensity) * dimRate + const { screenW, screenH, boxD } = store.dimensions + const spotX = store.config.lights.spotlightX + const spotY = store.config.lights.spotlightY + spotlight.position.set( + (screenW / 2) * spotX, + (screenH / 2) * spotY, + boxD * 0.1, + ) + spotlight.target.position.set( + -(screenW / 2) * spotX * 0.4, + -(screenH / 2) * spotY * 0.2, + -boxD * 0.6, + ) + spotlight.target.updateMatrixWorld() + spotlight.angle = (store.config.lights.spotlightAngle * Math.PI) / 180 + spotlight.penumbra = store.config.lights.spotlightPenumbra } renderer.render(scene, camera) diff --git a/src/data/defaults.ts b/src/data/defaults.ts index 883b5ba..c204047 100644 --- a/src/data/defaults.ts +++ b/src/data/defaults.ts @@ -16,10 +16,14 @@ export const DEFAULT_CONFIG: AppConfig = { webcamOffsetX: 0, webcamOffsetY: 0, lights: { - ambientIntensity: 0.4, - directionalIntensity: 2.4, - backlightIntensity: 1.6, - spotlightIntensity: 1.4, + ambientIntensity: 0.1, + directionalIntensity: 1.3, + backlightIntensity: 10.0, + spotlightIntensity: 1.3, + spotlightX: 0.4, + spotlightY: 1.0, + spotlightAngle: 48, + spotlightPenumbra: 1.0, }, shaders: { illustrationRare: { diff --git a/src/three/buildBox.ts b/src/three/buildBox.ts index c8715a8..db5c51b 100644 --- a/src/three/buildBox.ts +++ b/src/three/buildBox.ts @@ -1,7 +1,10 @@ import { AmbientLight, BufferGeometry, + ConeGeometry, + CylinderGeometry, DirectionalLight, + MeshBasicMaterial, Line, LineDashedMaterial, Mesh, @@ -313,22 +316,71 @@ export function buildBoxShell( backLight.name = 'solidBack' backLight.position.set(0, 0, -boxD * 0.7) scene.add(backLight) + + // Candle objects + lights along the bottom of the back wall + const candleSpacing = [-0.6, -0.3, 0, 0.3, 0.6] + const candleH = hh * 0.08 + const candleR = hh * 0.012 + const candleMat = new MeshStandardMaterial({ + color: 0xf5f0e0, + emissive: 0x222018, + emissiveIntensity: 0.2, + roughness: 0.8, + }) + for (let i = 0; i < candleSpacing.length; i++) { + const cx = hw * candleSpacing[i]! + const cy = -hh + candleH / 2 + const cz = -boxD * 0.85 + + // Candle body + const body = new Mesh(new CylinderGeometry(candleR, candleR, candleH, 8), candleMat) + body.position.set(cx, cy, cz) + body.receiveShadow = true + scene.add(body) + + // Flame (cone, transparent — faded in/out with candle light) + const flameH = candleH * 0.5 + const flameR = candleR * 0.6 + const flame = new Mesh( + new ConeGeometry(flameR, flameH, 6), + new MeshBasicMaterial({ + color: 0xffcc44, + transparent: true, + opacity: 0, + depthWrite: false, + }), + ) + flame.name = `candleFlame${i}` + flame.position.set(cx, -hh + candleH + flameH / 2, cz) + scene.add(flame) + + // Point light (off by default, animated in dim mode) + const light = new PointLight(0xffaa44, 0, boxD * 0.6, 0) + light.name = `candle${i}` + light.position.set(cx, -hh + candleH + flameH * 0.4, cz) + scene.add(light) + } } else { scene.add(new AmbientLight(0xffffff, 0.05)) } - // Head-tracked spotlight for dynamic shadows + // Fixed spotlight casting shadows onto the walls + const spotX = lights?.spotlightX ?? 0.8 + const spotY = lights?.spotlightY ?? 0.9 + const spotAngle = lights?.spotlightAngle ?? 48 + const spotPenumbra = lights?.spotlightPenumbra ?? 1.0 const spotI = lights?.spotlightIntensity ?? 0.6 - const spotlight = new SpotLight(0xffffff, spotI, boxD * 3, Math.PI / 5, 0.4, 1.5) + const spotlight = new SpotLight(0xffffff, spotI, 0, (spotAngle * Math.PI) / 180, spotPenumbra, 0) spotlight.name = 'headSpotlight' spotlight.castShadow = true - spotlight.shadow.mapSize.width = 1024 - spotlight.shadow.mapSize.height = 1024 + spotlight.shadow.mapSize.width = 4096 + spotlight.shadow.mapSize.height = 4096 + spotlight.shadow.radius = 10 spotlight.shadow.camera.near = 5 - spotlight.shadow.camera.far = boxD * 3 + spotlight.shadow.camera.far = dims.eyeDefaultZ + boxD spotlight.shadow.bias = -0.001 - spotlight.position.set(0, 0, dims.eyeDefaultZ * 0.5) - spotlight.target.position.set(0, 0, -boxD / 2) + spotlight.position.set(hw * spotX, hh * spotY, boxD * 0.1) + spotlight.target.position.set(-hw * spotX * 0.4, -hh * spotY * 0.2, -boxD * 0.6) scene.add(spotlight) scene.add(spotlight.target) } diff --git a/src/three/buildCard.ts b/src/three/buildCard.ts index 213b554..354b6a7 100644 --- a/src/three/buildCard.ts +++ b/src/three/buildCard.ts @@ -3,7 +3,9 @@ import { DoubleSide, Mesh, MeshBasicMaterial, + MeshDepthMaterial, PlaneGeometry, + RGBADepthPacking, RGBAFormat, ShaderMaterial, UnsignedByteType, @@ -152,7 +154,7 @@ export function buildCardMesh( uniforms.uBirthdayDank2Tex = { value: birthdayTextures?.dank2 ?? blackPixel } } - return new Mesh( + const mesh = new Mesh( cardGeo, new ShaderMaterial({ uniforms, @@ -162,6 +164,8 @@ export function buildCardMesh( transparent: true, }), ) + mesh.customDepthMaterial = new MeshDepthMaterial({ depthPacking: RGBADepthPacking }) + return mesh } export function buildActivationMaterial( diff --git a/src/types/index.ts b/src/types/index.ts index bc1fe71..ada51ba 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -3,6 +3,10 @@ export interface LightConfig { directionalIntensity: number backlightIntensity: number spotlightIntensity: number + spotlightX: number + spotlightY: number + spotlightAngle: number + spotlightPenumbra: number } export interface SceneConfig {