feat: complete Quantumancy web app — full frontend + docs
Frontend (React 18 + TS + Vite): - Landing: glitching hero, live /api/stats veil ticker, mode cards, featured spirits - Séance: three.js shader ghost (hue/form per entity, mood + audio-reactive), Ouija planchette board spelling utterances, transcript with TTS replay, entity dossier, direct contact streaming, passive/active listening - Modes: Wire Ghost telemetry panel, EVP mic anomaly detection, WebUSB RTL-SDR sweep + waterfall (hardware pass pending), Ouija/Direct Contact - Codex: public registry + entity dossiers, rarity tiers, i18n EN/ES complete - State: VeilSocket (reconnect/backoff), seance reducer, auth context - 72 vitest tests green; served by FastAPI at :7777 Docs: README + as-built plans 3-7
This commit is contained in:
49
frontend/src/three/GhostCanvas.tsx
Normal file
49
frontend/src/three/GhostCanvas.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { GhostScene } from './GhostScene'
|
||||
import type { GhostMood, GhostVisualState } from './GhostScene'
|
||||
|
||||
export type GhostCanvasProps = {
|
||||
visual: GhostVisualState | null
|
||||
mood: GhostMood
|
||||
/** Called each frame to get the speaking amplitude (0..1). */
|
||||
getAmplitude?: () => number
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** React wrapper: owns the canvas element and the GhostScene lifecycle. */
|
||||
export function GhostCanvas({ visual, mood, getAmplitude, className }: GhostCanvasProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null)
|
||||
const sceneRef = useRef<GhostScene | null>(null)
|
||||
const ampRef = useRef(getAmplitude)
|
||||
ampRef.current = getAmplitude
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
const scene = new GhostScene(canvas)
|
||||
sceneRef.current = scene
|
||||
scene.start()
|
||||
const ampTimer = window.setInterval(() => {
|
||||
scene.setSpeakAmplitude(ampRef.current?.() ?? 0)
|
||||
}, 50)
|
||||
return () => {
|
||||
window.clearInterval(ampTimer)
|
||||
scene.dispose()
|
||||
sceneRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
sceneRef.current?.setVisual(visual)
|
||||
}, [visual])
|
||||
|
||||
useEffect(() => {
|
||||
sceneRef.current?.setMood(mood)
|
||||
}, [mood])
|
||||
|
||||
return (
|
||||
<div className={className} style={{ position: 'absolute', inset: 0, overflow: 'hidden' }}>
|
||||
<canvas ref={canvasRef} style={{ width: '100%', height: '100%', display: 'block' }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
477
frontend/src/three/GhostScene.ts
Normal file
477
frontend/src/three/GhostScene.ts
Normal file
@@ -0,0 +1,477 @@
|
||||
// The 3D ghost scene: a smoky, wavy, translucent spirit rendered with
|
||||
// custom shaders — fbm value-noise vertex displacement on a lathed figure,
|
||||
// additive particle wisps, ground mist, fog, slow camera drift.
|
||||
// Reacts to seance state via uniforms (energy / speaking amplitude / hue / form).
|
||||
|
||||
import * as THREE from 'three'
|
||||
import type { GhostForm } from '../lib/types'
|
||||
|
||||
export type GhostMood = 'idle' | 'attuning' | 'gathering' | 'speaking'
|
||||
|
||||
export type GhostVisualState = {
|
||||
hue: number // 0..360
|
||||
form: GhostForm
|
||||
}
|
||||
|
||||
const NOISE_GLSL = /* glsl */ `
|
||||
float hash(vec3 p) {
|
||||
p = fract(p * 0.3183099 + 0.1);
|
||||
p *= 17.0;
|
||||
return fract(p.x * p.y * p.z * (p.x + p.y + p.z));
|
||||
}
|
||||
float vnoise(vec3 x) {
|
||||
vec3 i = floor(x);
|
||||
vec3 f = fract(x);
|
||||
f = f * f * (3.0 - 2.0 * f);
|
||||
return mix(
|
||||
mix(mix(hash(i + vec3(0,0,0)), hash(i + vec3(1,0,0)), f.x),
|
||||
mix(hash(i + vec3(0,1,0)), hash(i + vec3(1,1,0)), f.x), f.y),
|
||||
mix(mix(hash(i + vec3(0,0,1)), hash(i + vec3(1,0,1)), f.x),
|
||||
mix(hash(i + vec3(0,1,1)), hash(i + vec3(1,1,1)), f.x), f.y),
|
||||
f.z);
|
||||
}
|
||||
float fbm(vec3 p) {
|
||||
float v = 0.0;
|
||||
float a = 0.5;
|
||||
for (int i = 0; i < 4; i++) {
|
||||
v += a * vnoise(p);
|
||||
p *= 2.03;
|
||||
a *= 0.5;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
`
|
||||
|
||||
const GHOST_VERT = /* glsl */ `
|
||||
uniform float uTime;
|
||||
uniform float uEnergy; // 0..1 swirl intensity
|
||||
uniform float uSpeak; // 0..1 voice amplitude pulse
|
||||
uniform float uFormStretch; // form-driven vertical stretch
|
||||
varying vec2 vUv;
|
||||
varying float vNoise;
|
||||
varying vec3 vPos;
|
||||
${NOISE_GLSL}
|
||||
void main() {
|
||||
vUv = uv;
|
||||
vec3 p = position;
|
||||
p.y *= uFormStretch;
|
||||
|
||||
float swirl = uTime * (0.35 + uEnergy * 1.6);
|
||||
float n = fbm(vec3(p.x * 1.4 + swirl, p.y * 0.9 - uTime * (0.5 + uEnergy), p.z * 1.4));
|
||||
float n2 = fbm(vec3(p.z * 2.2 - swirl * 0.7, p.y * 1.3 + uTime * 0.4, p.x * 2.2));
|
||||
|
||||
// Radial displacement, stronger toward the base (smoke trail) and with energy.
|
||||
float base = smoothstep(1.0, -1.0, p.y);
|
||||
float amp = (0.12 + uEnergy * 0.35 + uSpeak * 0.25) * (0.4 + base);
|
||||
p.x += (n - 0.5) * 2.0 * amp;
|
||||
p.z += (n2 - 0.5) * 2.0 * amp;
|
||||
|
||||
// Breathing pulse while speaking.
|
||||
float pulse = 1.0 + uSpeak * 0.18 * sin(uTime * 9.0 + p.y * 3.0);
|
||||
p.xz *= pulse;
|
||||
|
||||
vNoise = n;
|
||||
vPos = p;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0);
|
||||
}
|
||||
`
|
||||
|
||||
const GHOST_FRAG = /* glsl */ `
|
||||
uniform vec3 uColor;
|
||||
uniform float uTime;
|
||||
uniform float uEnergy;
|
||||
uniform float uSpeak;
|
||||
uniform float uOpacity;
|
||||
uniform float uDark; // shade form absorbs light
|
||||
varying vec2 vUv;
|
||||
varying float vNoise;
|
||||
varying vec3 vPos;
|
||||
void main() {
|
||||
// Fade top and bottom, fade with noise, brighter core.
|
||||
float vert = smoothstep(0.0, 0.25, vUv.y) * smoothstep(1.0, 0.55, vUv.y);
|
||||
float body = smoothstep(0.25, 0.75, vNoise + uSpeak * 0.2);
|
||||
float a = vert * body * (0.35 + uEnergy * 0.5 + uSpeak * 0.35) * uOpacity;
|
||||
vec3 col = uColor * (0.55 + uEnergy * 0.8 + uSpeak * 0.9);
|
||||
col = mix(col, col * 0.12, uDark);
|
||||
// Flicker.
|
||||
col *= 0.92 + 0.08 * sin(uTime * 13.7 + vPos.y * 5.0);
|
||||
gl_FragColor = vec4(col, a);
|
||||
}
|
||||
`
|
||||
|
||||
const WISP_VERT = /* glsl */ `
|
||||
uniform float uTime;
|
||||
uniform float uEnergy;
|
||||
uniform float uSpeak;
|
||||
uniform float uMoteSpeed;
|
||||
attribute float aSeed;
|
||||
varying float vSeed;
|
||||
void main() {
|
||||
vSeed = aSeed;
|
||||
vec3 p = position;
|
||||
float t = uTime * (0.12 + 0.5 * uEnergy) * uMoteSpeed + aSeed * 17.0;
|
||||
float r = length(p.xz) * (1.0 + 0.2 * sin(t * 0.9 + aSeed * 6.28));
|
||||
float ang = atan(p.z, p.x) + t * (0.3 + aSeed * 0.2);
|
||||
p.x = cos(ang) * r;
|
||||
p.z = sin(ang) * r;
|
||||
p.y += sin(t + aSeed * 12.0) * (0.35 + uSpeak * 0.4);
|
||||
vec4 mv = modelViewMatrix * vec4(p, 1.0);
|
||||
gl_PointSize = (2.0 + aSeed * 5.0 + uSpeak * 3.0) * (18.0 / -mv.z);
|
||||
gl_Position = projectionMatrix * mv;
|
||||
}
|
||||
`
|
||||
|
||||
const WISP_FRAG = /* glsl */ `
|
||||
uniform vec3 uColor;
|
||||
uniform float uOpacity;
|
||||
varying float vSeed;
|
||||
void main() {
|
||||
vec2 c = gl_PointCoord - 0.5;
|
||||
float d = length(c);
|
||||
float a = smoothstep(0.5, 0.0, d);
|
||||
gl_FragColor = vec4(uColor * (0.7 + vSeed * 0.6), a * uOpacity);
|
||||
}
|
||||
`
|
||||
|
||||
const MIST_VERT = /* glsl */ `
|
||||
varying vec2 vUv;
|
||||
void main() {
|
||||
vUv = uv;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
}
|
||||
`
|
||||
|
||||
const MIST_FRAG = /* glsl */ `
|
||||
uniform float uTime;
|
||||
uniform vec3 uColor;
|
||||
uniform float uOpacity;
|
||||
varying vec2 vUv;
|
||||
${NOISE_GLSL}
|
||||
void main() {
|
||||
vec2 c = vUv - 0.5;
|
||||
float r = length(c) * 2.0;
|
||||
float n = fbm(vec3(vUv * 4.0, uTime * 0.08));
|
||||
float a = smoothstep(1.0, 0.1, r) * n * uOpacity;
|
||||
gl_FragColor = vec4(uColor * 0.6, a);
|
||||
}
|
||||
`
|
||||
|
||||
function hsl(hue: number, s: number, l: number): THREE.Color {
|
||||
return new THREE.Color().setHSL(((hue % 360) + 360) % 360 / 360, s, l)
|
||||
}
|
||||
|
||||
/** Build the lathed "hooded wisp" figure profile. */
|
||||
function buildFigureGeometry(): THREE.LatheGeometry {
|
||||
const pts: THREE.Vector2[] = []
|
||||
// Profile: wide smoky base → waist → shoulders → head → dissolving tip.
|
||||
const profile: Array<[number, number]> = [
|
||||
[0.0, -1.6],
|
||||
[0.9, -1.55],
|
||||
[0.75, -1.1],
|
||||
[0.5, -0.55],
|
||||
[0.42, -0.1],
|
||||
[0.5, 0.25],
|
||||
[0.46, 0.55],
|
||||
[0.3, 0.8],
|
||||
[0.34, 0.95], // hood
|
||||
[0.22, 1.15],
|
||||
[0.08, 1.35],
|
||||
[0.0, 1.55],
|
||||
]
|
||||
for (const [x, y] of profile) pts.push(new THREE.Vector2(x, y))
|
||||
const geo = new THREE.LatheGeometry(pts, 48)
|
||||
geo.computeVertexNormals()
|
||||
return geo
|
||||
}
|
||||
|
||||
type FormParams = {
|
||||
scale: number
|
||||
stretch: number
|
||||
moteCount: number
|
||||
moteSpeed: number
|
||||
dark: number
|
||||
opacity: number
|
||||
}
|
||||
|
||||
function formParams(form: GhostForm): FormParams {
|
||||
switch (form) {
|
||||
case 'fairy':
|
||||
return { scale: 0.55, stretch: 0.9, moteCount: 260, moteSpeed: 2.2, dark: 0, opacity: 1 }
|
||||
case 'banshee':
|
||||
return { scale: 1.05, stretch: 1.55, moteCount: 140, moteSpeed: 0.8, dark: 0.15, opacity: 0.95 }
|
||||
case 'shade':
|
||||
return { scale: 1.1, stretch: 1.15, moteCount: 90, moteSpeed: 0.5, dark: 0.85, opacity: 0.9 }
|
||||
case 'wisp':
|
||||
default:
|
||||
return { scale: 0.85, stretch: 1.0, moteCount: 160, moteSpeed: 1.2, dark: 0, opacity: 1 }
|
||||
}
|
||||
}
|
||||
|
||||
export class GhostScene {
|
||||
private renderer: THREE.WebGLRenderer
|
||||
private scene = new THREE.Scene()
|
||||
private camera: THREE.PerspectiveCamera
|
||||
private canvas: HTMLCanvasElement
|
||||
|
||||
private ghostMat: THREE.ShaderMaterial
|
||||
private ghost: THREE.Mesh
|
||||
private veil: THREE.Mesh // banshee trailing veil
|
||||
private wispMat: THREE.ShaderMaterial
|
||||
private wisps: THREE.Points
|
||||
private mistMat: THREE.ShaderMaterial
|
||||
private mists: THREE.Mesh[] = []
|
||||
private farWisps: THREE.Points
|
||||
private farWispMat: THREE.PointsMaterial
|
||||
|
||||
private raf = 0
|
||||
private running = false
|
||||
private clock = new THREE.Clock()
|
||||
private resizeObserver: ResizeObserver | null = null
|
||||
|
||||
private mood: GhostMood = 'idle'
|
||||
private visual: GhostVisualState | null = null
|
||||
private speakAmp = 0
|
||||
private smoothedAmp = 0
|
||||
private energy = 0.15
|
||||
private disposed = false
|
||||
|
||||
constructor(canvas: HTMLCanvasElement) {
|
||||
this.canvas = canvas
|
||||
this.renderer = new THREE.WebGLRenderer({
|
||||
canvas,
|
||||
antialias: true,
|
||||
alpha: true,
|
||||
powerPreference: 'high-performance',
|
||||
})
|
||||
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
|
||||
this.camera = new THREE.PerspectiveCamera(50, 1, 0.1, 60)
|
||||
this.camera.position.set(0, 0.6, 6.2)
|
||||
this.scene.fog = new THREE.FogExp2(0x07070d, 0.09)
|
||||
|
||||
// --- Ghost figure ---
|
||||
this.ghostMat = new THREE.ShaderMaterial({
|
||||
vertexShader: GHOST_VERT,
|
||||
fragmentShader: GHOST_FRAG,
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
blending: THREE.AdditiveBlending,
|
||||
side: THREE.DoubleSide,
|
||||
uniforms: {
|
||||
uTime: { value: 0 },
|
||||
uEnergy: { value: 0.15 },
|
||||
uSpeak: { value: 0 },
|
||||
uFormStretch: { value: 1 },
|
||||
uColor: { value: hsl(150, 0.9, 0.65) },
|
||||
uOpacity: { value: 1 },
|
||||
uDark: { value: 0 },
|
||||
},
|
||||
})
|
||||
this.ghost = new THREE.Mesh(buildFigureGeometry(), this.ghostMat)
|
||||
this.ghost.position.set(0, 0.4, 0)
|
||||
this.ghost.visible = false
|
||||
this.scene.add(this.ghost)
|
||||
|
||||
// Trailing veil (banshee): a second, elongated, fainter copy behind.
|
||||
this.veil = new THREE.Mesh(buildFigureGeometry(), this.ghostMat)
|
||||
this.veil.position.set(0.35, 0.1, -0.6)
|
||||
this.veil.scale.set(0.8, 1.3, 0.8)
|
||||
this.veil.visible = false
|
||||
this.scene.add(this.veil)
|
||||
|
||||
// --- Orbiting wisps / motes ---
|
||||
this.wispMat = new THREE.ShaderMaterial({
|
||||
vertexShader: WISP_VERT,
|
||||
fragmentShader: WISP_FRAG,
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
blending: THREE.AdditiveBlending,
|
||||
uniforms: {
|
||||
uTime: { value: 0 },
|
||||
uEnergy: { value: 0.15 },
|
||||
uSpeak: { value: 0 },
|
||||
uMoteSpeed: { value: 1.2 },
|
||||
uColor: { value: hsl(150, 0.9, 0.7) },
|
||||
uOpacity: { value: 0.55 },
|
||||
},
|
||||
})
|
||||
this.wisps = new THREE.Points(this.buildWispGeometry(260), this.wispMat)
|
||||
this.wisps.position.set(0, 0.4, 0)
|
||||
this.wisps.visible = false
|
||||
this.scene.add(this.wisps)
|
||||
|
||||
// --- Ground mist planes ---
|
||||
this.mistMat = new THREE.ShaderMaterial({
|
||||
vertexShader: MIST_VERT,
|
||||
fragmentShader: MIST_FRAG,
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
blending: THREE.AdditiveBlending,
|
||||
uniforms: {
|
||||
uTime: { value: 0 },
|
||||
uColor: { value: hsl(160, 0.5, 0.4) },
|
||||
uOpacity: { value: 0.16 },
|
||||
},
|
||||
})
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const m = new THREE.Mesh(new THREE.PlaneGeometry(14, 14), this.mistMat)
|
||||
m.rotation.x = -Math.PI / 2
|
||||
m.position.set(0, -1.5 + i * 0.22, 0)
|
||||
m.rotation.z = (i * Math.PI) / 3
|
||||
this.scene.add(m)
|
||||
this.mists.push(m)
|
||||
}
|
||||
|
||||
// --- Far-off ambient wisps (no entity summoned) ---
|
||||
const farGeo = new THREE.BufferGeometry()
|
||||
const farCount = 60
|
||||
const farPos = new Float32Array(farCount * 3)
|
||||
for (let i = 0; i < farCount; i++) {
|
||||
farPos[i * 3] = (Math.random() - 0.5) * 30
|
||||
farPos[i * 3 + 1] = Math.random() * 6 - 1
|
||||
farPos[i * 3 + 2] = -6 - Math.random() * 18
|
||||
}
|
||||
farGeo.setAttribute('position', new THREE.BufferAttribute(farPos, 3))
|
||||
this.farWispMat = new THREE.PointsMaterial({
|
||||
color: 0x7cffb2,
|
||||
size: 0.08,
|
||||
transparent: true,
|
||||
opacity: 0.35,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false,
|
||||
})
|
||||
this.farWisps = new THREE.Points(farGeo, this.farWispMat)
|
||||
this.scene.add(this.farWisps)
|
||||
|
||||
this.handleResize()
|
||||
this.resizeObserver = new ResizeObserver(() => this.handleResize())
|
||||
if (canvas.parentElement) this.resizeObserver.observe(canvas.parentElement)
|
||||
}
|
||||
|
||||
private buildWispGeometry(count: number): THREE.BufferGeometry {
|
||||
const geo = new THREE.BufferGeometry()
|
||||
const pos = new Float32Array(count * 3)
|
||||
const seed = new Float32Array(count)
|
||||
for (let i = 0; i < count; i++) {
|
||||
const r = 0.5 + Math.random() * 1.6
|
||||
const a = Math.random() * Math.PI * 2
|
||||
pos[i * 3] = Math.cos(a) * r
|
||||
pos[i * 3 + 1] = Math.random() * 3 - 1.4
|
||||
pos[i * 3 + 2] = Math.sin(a) * r
|
||||
seed[i] = Math.random()
|
||||
}
|
||||
geo.setAttribute('position', new THREE.BufferAttribute(pos, 3))
|
||||
geo.setAttribute('aSeed', new THREE.BufferAttribute(seed, 1))
|
||||
return geo
|
||||
}
|
||||
|
||||
setMood(mood: GhostMood): void {
|
||||
this.mood = mood
|
||||
}
|
||||
|
||||
/** Call every frame with the audio player's instantaneous amplitude. */
|
||||
setSpeakAmplitude(amp: number): void {
|
||||
this.speakAmp = Math.max(0, Math.min(1, amp))
|
||||
}
|
||||
|
||||
setVisual(visual: GhostVisualState | null): void {
|
||||
this.visual = visual
|
||||
if (!visual) {
|
||||
this.ghost.visible = false
|
||||
this.veil.visible = false
|
||||
this.wisps.visible = false
|
||||
return
|
||||
}
|
||||
const params = formParams(visual.form)
|
||||
const color = hsl(visual.hue, 0.85, 0.62)
|
||||
const ghostU = this.ghostMat.uniforms
|
||||
ghostU.uColor.value = color
|
||||
ghostU.uFormStretch.value = params.stretch
|
||||
ghostU.uDark.value = params.dark
|
||||
ghostU.uOpacity.value = params.opacity
|
||||
this.wispMat.uniforms.uColor.value = hsl(visual.hue, 0.9, 0.7)
|
||||
this.wispMat.uniforms.uMoteSpeed.value = params.moteSpeed
|
||||
this.mistMat.uniforms.uColor.value = hsl(visual.hue, 0.5, 0.4)
|
||||
this.ghost.scale.setScalar(params.scale)
|
||||
this.ghost.visible = true
|
||||
this.wisps.visible = true
|
||||
this.veil.visible = visual.form === 'banshee'
|
||||
// Rebuild motes if the form wants a different count.
|
||||
const current = this.wisps.geometry.getAttribute('position')?.count ?? 0
|
||||
if (current !== params.moteCount) {
|
||||
this.wisps.geometry.dispose()
|
||||
this.wisps.geometry = this.buildWispGeometry(params.moteCount)
|
||||
}
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (this.running || this.disposed) return
|
||||
this.running = true
|
||||
this.clock.start()
|
||||
const loop = () => {
|
||||
if (!this.running) return
|
||||
this.raf = requestAnimationFrame(loop)
|
||||
this.renderFrame()
|
||||
}
|
||||
this.raf = requestAnimationFrame(loop)
|
||||
}
|
||||
|
||||
private renderFrame(): void {
|
||||
const t = this.clock.getElapsedTime()
|
||||
|
||||
// Energy target from mood; smooth toward it.
|
||||
const target =
|
||||
this.mood === 'gathering' ? 0.9 : this.mood === 'attuning' ? 0.4 : this.mood === 'speaking' ? 0.55 : 0.15
|
||||
this.energy += (target - this.energy) * 0.04
|
||||
this.smoothedAmp += (this.speakAmp - this.smoothedAmp) * 0.25
|
||||
|
||||
const ghostU = this.ghostMat.uniforms
|
||||
ghostU.uTime.value = t
|
||||
ghostU.uEnergy.value = this.energy
|
||||
ghostU.uSpeak.value = this.smoothedAmp
|
||||
this.wispMat.uniforms.uTime.value = t
|
||||
this.wispMat.uniforms.uEnergy.value = this.energy
|
||||
this.wispMat.uniforms.uSpeak.value = this.smoothedAmp
|
||||
this.mistMat.uniforms.uTime.value = t
|
||||
this.mistMat.uniforms.uOpacity.value = this.visual ? 0.16 : 0.1
|
||||
|
||||
// Idle drift / slow camera wander.
|
||||
this.ghost.position.y = 0.4 + Math.sin(t * 0.6) * 0.12
|
||||
this.ghost.rotation.y = Math.sin(t * 0.23) * 0.3
|
||||
this.veil.position.y = this.ghost.position.y - 0.3
|
||||
this.camera.position.x = Math.sin(t * 0.11) * 0.55
|
||||
this.camera.position.y = 0.6 + Math.sin(t * 0.07) * 0.25
|
||||
this.camera.lookAt(0, 0.4, 0)
|
||||
|
||||
// Far wisps drift slowly.
|
||||
this.farWisps.rotation.y = t * 0.01
|
||||
this.farWispMat.opacity = this.visual ? 0.12 : 0.3 + 0.1 * Math.sin(t * 0.5)
|
||||
|
||||
this.renderer.render(this.scene, this.camera)
|
||||
}
|
||||
|
||||
private handleResize(): void {
|
||||
const parent = this.canvas.parentElement
|
||||
const w = parent?.clientWidth || window.innerWidth
|
||||
const h = parent?.clientHeight || window.innerHeight
|
||||
if (w === 0 || h === 0) return
|
||||
this.renderer.setSize(w, h, false)
|
||||
this.camera.aspect = w / h
|
||||
this.camera.updateProjectionMatrix()
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.disposed = true
|
||||
this.running = false
|
||||
cancelAnimationFrame(this.raf)
|
||||
this.resizeObserver?.disconnect()
|
||||
this.scene.traverse((obj) => {
|
||||
if (obj instanceof THREE.Mesh || obj instanceof THREE.Points) {
|
||||
obj.geometry.dispose()
|
||||
const mat = obj.material
|
||||
if (Array.isArray(mat)) mat.forEach((m) => m.dispose())
|
||||
else mat.dispose()
|
||||
}
|
||||
})
|
||||
this.renderer.dispose()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user