2dedc35turing-sphere: reaction-diffusion on the sphere, spectral solver on WebGPUJeremy Magland 1import * as THREE from 'three';
2import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
4/**
5 * Three.js scene wrapper: a single indexed triangle mesh with static
6 * per-vertex positions and dynamic per-vertex colors, orbit controls, and
7 * optional camera synchronization with sibling scenes.
8 *
9 * Adapted from figpack's SphereEmbedding view (figpack_experimental).
10 */
11export class SphereScene {
12 #scene: THREE.Scene;
13 #camera: THREE.PerspectiveCamera;
14 #renderer: THREE.WebGLRenderer;
15 #controls: OrbitControls;
16 #geometry: THREE.BufferGeometry;
17 #mesh: THREE.Mesh;
18 #animationId: number | null = null;
19 #defaultCameraState: {
20 position: THREE.Vector3;
21 target: THREE.Vector3;
22 } | null = null;
23 #syncing = false;
24 #lastW = -1;
25 #lastH = -1;
27 constructor(
28 container: HTMLElement,
29 numVertices: number,
30 indices: Uint32Array,
31 positions: Float32Array,
32 background = '#14161c',
33 ) {
34 this.#scene = new THREE.Scene();
35 this.#scene.background = new THREE.Color(background);
37 this.#camera = new THREE.PerspectiveCamera(50, 1, 0.01, 1000);
39 this.#renderer = new THREE.WebGLRenderer({ antialias: true });
40 this.#renderer.setPixelRatio(window.devicePixelRatio || 1);
41 // The canvas always fills its container via CSS; resize() then only
42 // updates the drawing buffer
43 this.#renderer.domElement.style.width = '100%';
44 this.#renderer.domElement.style.height = '100%';
45 this.#renderer.domElement.style.display = 'block';
46 container.appendChild(this.#renderer.domElement);
48 // Lighting: ambient plus a headlight attached to the camera so the
49 // surface stays lit from the viewing direction as it is rotated
50 this.#scene.add(new THREE.AmbientLight(0xffffff, 0.65));
51 const headlight = new THREE.DirectionalLight(0xffffff, 1.6);
52 headlight.position.set(0.5, 0.8, 1);
53 this.#camera.add(headlight);
54 this.#scene.add(this.#camera);
56 this.#geometry = new THREE.BufferGeometry();
57 const positionAttr = new THREE.BufferAttribute(positions, 3);
58 const colorAttr = new THREE.BufferAttribute(
59 new Float32Array(numVertices * 3),
60 3,
61 );
62 colorAttr.setUsage(THREE.DynamicDrawUsage);
63 this.#geometry.setAttribute('position', positionAttr);
64 this.#geometry.setAttribute('color', colorAttr);
65 this.#geometry.setIndex(new THREE.BufferAttribute(indices, 1));
66 this.#geometry.computeVertexNormals();
67 this.#geometry.computeBoundingSphere();
69 const material = new THREE.MeshPhongMaterial({
70 vertexColors: true,
71 side: THREE.DoubleSide,
72 shininess: 25,
73 specular: new THREE.Color(0x222222),
74 });
75 this.#mesh = new THREE.Mesh(this.#geometry, material);
76 this.#scene.add(this.#mesh);
78 this.#controls = new OrbitControls(this.#camera, this.#renderer.domElement);
79 this.#controls.enableDamping = true;
80 this.#controls.dampingFactor = 0.1;
82 this.#animate();
83 }
85 #animate = () => {
86 this.#animationId = requestAnimationFrame(this.#animate);
87 this.#controls.update();
88 this.#renderer.render(this.#scene, this.#camera);
89 };
91 updateColors(colors: Float32Array): void {
92 const attr = this.#geometry.getAttribute('color') as THREE.BufferAttribute;
93 (attr.array as Float32Array).set(colors);
94 attr.needsUpdate = true;
95 }
97 /** Mirror this scene's camera whenever the other scene's controls move. */
98 syncCamerasWith(other: SphereScene): void {
99 const follow = (src: SphereScene, dst: SphereScene) => {
100 src.#controls.addEventListener('change', () => {
101 if (dst.#syncing) return;
102 src.#syncing = true;
103 dst.#camera.position.copy(src.#camera.position);
104 dst.#camera.zoom = src.#camera.zoom;
105 dst.#camera.updateProjectionMatrix();
106 dst.#controls.target.copy(src.#controls.target);
107 dst.#controls.update();
108 src.#syncing = false;
109 });
110 };
111 follow(this, other);
112 follow(other, this);
113 }
115 /** Position the camera to comfortably frame the geometry. */
116 fitCamera(): void {
117 this.#geometry.computeBoundingSphere();
118 const bs = this.#geometry.boundingSphere;
119 if (!bs) return;
120 const radius = Math.max(bs.radius, 1e-6);
121 const distance = radius * 2.6;
122 this.#controls.target.copy(bs.center);
123 this.#camera.position.set(
124 bs.center.x + distance * 0.55,
125 bs.center.y + distance * 0.35,
126 bs.center.z + distance * 0.75,
127 );
128 this.#camera.near = radius * 0.01;
129 this.#camera.far = radius * 100;
130 this.#camera.updateProjectionMatrix();
131 this.#controls.update();
132 this.#defaultCameraState = {
133 position: this.#camera.position.clone(),
134 target: this.#controls.target.clone(),
135 };
136 }
138 resetCamera(): void {
139 if (this.#defaultCameraState) {
140 this.#camera.position.copy(this.#defaultCameraState.position);
141 this.#controls.target.copy(this.#defaultCameraState.target);
142 this.#controls.update();
143 } else {
144 this.fitCamera();
145 }
146 }
148 resize(width: number, height: number): void {
149 // Setting canvas.width clears the canvas even at the same value, which
150 // shows as a blank flash until the next render — skip no-op resizes.
151 if (width === this.#lastW && height === this.#lastH) return;
152 this.#lastW = width;
153 this.#lastH = height;
154 this.#camera.aspect = width / Math.max(1, height);
155 this.#camera.updateProjectionMatrix();
156 // updateStyle=false: the canvas keeps its 100%/100% CSS sizing
157 this.#renderer.setSize(width, height, false);
158 }
160 dispose(): void {
161 if (this.#animationId !== null) {
162 cancelAnimationFrame(this.#animationId);
163 this.#animationId = null;
164 }
165 this.#controls.dispose();
166 this.#geometry.dispose();
167 (this.#mesh.material as THREE.Material).dispose();
168 if (this.#renderer.domElement.parentNode) {
169 this.#renderer.domElement.parentNode.removeChild(
170 this.#renderer.domElement,
171 );
172 }
173 this.#renderer.dispose();
174 }
175}