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 dynamic
6 * per-vertex positions and colors, orbit controls, and optional camera
7 * synchronization with sibling scenes. The topology is fixed by the grid; the
8 * positions are the surface, so they change when the geometry or the morph
9 * does, and the colors every frame.
10 *
11 * Rendering is on demand: the animation loop ticks every frame (it has to,
12 * to drive OrbitControls damping), but only re-renders when the colors,
13 * camera, or canvas size actually changed.
14 *
15 * Adapted from figpack's SphereEmbedding view (figpack_experimental).
16 */
17export class SphereScene {
18 #scene: THREE.Scene;
19 #camera: THREE.PerspectiveCamera;
20 #renderer: THREE.WebGLRenderer;
21 #controls: OrbitControls;
22 #geometry: THREE.BufferGeometry;
23 #mesh: THREE.Mesh;
24 #animationId: number | null = null;
25 #defaultCameraState: {
26 position: THREE.Vector3;
27 target: THREE.Vector3;
28 } | null = null;
29 #syncing = false;
30 #needsRender = true;
31 #lastW = -1;
32 #lastH = -1;
34 constructor(
35 container: HTMLElement,
36 numVertices: number,
37 indices: Uint32Array,
38 positions: Float32Array,
39 background = '#14161c',
40 ) {
41 this.#scene = new THREE.Scene();
42 this.#scene.background = new THREE.Color(background);
44 this.#camera = new THREE.PerspectiveCamera(50, 1, 0.01, 1000);
46 this.#renderer = new THREE.WebGLRenderer({ antialias: true });
47 this.#renderer.setPixelRatio(window.devicePixelRatio || 1);
48 // The canvas always fills its container via CSS; resize() then only
49 // updates the drawing buffer
50 this.#renderer.domElement.style.width = '100%';
51 this.#renderer.domElement.style.height = '100%';
52 this.#renderer.domElement.style.display = 'block';
53 container.appendChild(this.#renderer.domElement);
55 // Lighting: ambient plus a headlight attached to the camera so the
56 // surface stays lit from the viewing direction as it is rotated
57 this.#scene.add(new THREE.AmbientLight(0xffffff, 0.65));
58 const headlight = new THREE.DirectionalLight(0xffffff, 1.6);
59 headlight.position.set(0.5, 0.8, 1);
60 this.#camera.add(headlight);
61 this.#scene.add(this.#camera);
63 this.#geometry = new THREE.BufferGeometry();
64 // Positions move with the morph slider, so they are dynamic too.
65 const positionAttr = new THREE.BufferAttribute(positions, 3);
66 positionAttr.setUsage(THREE.DynamicDrawUsage);
67 const colorAttr = new THREE.BufferAttribute(
68 new Float32Array(numVertices * 3),
69 3,
70 );
71 colorAttr.setUsage(THREE.DynamicDrawUsage);
72 this.#geometry.setAttribute('position', positionAttr);
73 this.#geometry.setAttribute('color', colorAttr);
74 this.#geometry.setIndex(new THREE.BufferAttribute(indices, 1));
75 this.#geometry.computeVertexNormals();
76 this.#geometry.computeBoundingSphere();
78 const material = new THREE.MeshPhongMaterial({
79 vertexColors: true,
80 side: THREE.DoubleSide,
81 shininess: 25,
82 specular: new THREE.Color(0x222222),
83 });
84 this.#mesh = new THREE.Mesh(this.#geometry, material);
85 this.#scene.add(this.#mesh);
87 this.#controls = new OrbitControls(this.#camera, this.#renderer.domElement);
88 this.#controls.enableDamping = true;
89 this.#controls.dampingFactor = 0.1;
90 // Fires on user input and on every damping-tail update, so the flag stays
91 // set until the camera has fully settled.
92 this.#controls.addEventListener('change', () => {
93 this.#needsRender = true;
94 });
96 this.#animate();
97 }
99 #animate = () => {
100 this.#animationId = requestAnimationFrame(this.#animate);
101 this.#controls.update();
102 if (!this.#needsRender) return;
103 this.#needsRender = false;
104 this.#renderer.render(this.#scene, this.#camera);
105 };
107 updateColors(colors: Float32Array): void {
108 const attr = this.#geometry.getAttribute('color') as THREE.BufferAttribute;
109 (attr.array as Float32Array).set(colors);
110 attr.needsUpdate = true;
111 this.#needsRender = true;
112 }
114 /**
115 * Move the vertices — for the sphere/surface morph. Normals have to be
116 * recomputed with them or the shading stays that of the old shape, which is
117 * the whole thing the eye reads a curved surface by.
118 */
119 updatePositions(positions: Float32Array): void {
120 const attr = this.#geometry.getAttribute('position') as THREE.BufferAttribute;
121 (attr.array as Float32Array).set(positions);
122 attr.needsUpdate = true;
123 this.#geometry.computeVertexNormals();
124 this.#geometry.computeBoundingSphere();
125 this.#needsRender = true;
126 }
128 /** The renderer's canvas, for capturing frames. */
129 get canvas(): HTMLCanvasElement {
130 return this.#renderer.domElement;
131 }
133 /**
134 * Render immediately, outside the animation loop. A WebGL canvas without
135 * preserveDrawingBuffer keeps its drawing buffer only until the browser next
136 * composites, so a capturer must render and copy within one task.
137 */
138 renderNow(): void {
139 this.#needsRender = false;
140 this.#renderer.render(this.#scene, this.#camera);
141 }
143 /** Mirror this scene's camera whenever the other scene's controls move. */
144 syncCamerasWith(other: SphereScene): void {
145 const follow = (src: SphereScene, dst: SphereScene) => {
146 src.#controls.addEventListener('change', () => {
147 if (dst.#syncing) return;
148 src.#syncing = true;
149 dst.#camera.position.copy(src.#camera.position);
150 dst.#camera.zoom = src.#camera.zoom;
151 dst.#camera.updateProjectionMatrix();
152 dst.#controls.target.copy(src.#controls.target);
153 dst.#controls.update();
154 dst.#needsRender = true;
155 src.#syncing = false;
156 });
157 };
158 follow(this, other);
159 follow(other, this);
160 }
162 /** Orbit the camera about the up axis by `angle` radians, keeping the
163 * target. Synced sibling scenes follow via their controls, as with a drag. */
164 orbitBy(angle: number): void {
165 const offset = this.#camera.position.clone().sub(this.#controls.target);
166 offset.applyAxisAngle(this.#camera.up, angle);
167 this.#camera.position.copy(this.#controls.target).add(offset);
168 this.#controls.update();
169 this.#needsRender = true;
170 }
172 /** Camera pose, for carrying the view across a scene rebuild. */
173 cameraState(): { position: THREE.Vector3; target: THREE.Vector3; zoom: number } {
174 return {
175 position: this.#camera.position.clone(),
176 target: this.#controls.target.clone(),
177 zoom: this.#camera.zoom,
178 };
179 }
181 setCameraState(s: {
182 position: THREE.Vector3;
183 target: THREE.Vector3;
184 zoom: number;
185 }): void {
186 this.#camera.position.copy(s.position);
187 this.#camera.zoom = s.zoom;
188 this.#camera.updateProjectionMatrix();
189 this.#controls.target.copy(s.target);
190 this.#controls.update();
191 this.#needsRender = true;
192 }
194 /**
195 * Position the camera to comfortably frame the geometry.
196 *
197 * The distance is generous on purpose. The bounding sphere is of the surface
198 * currently loaded, but the camera is *kept* across a geometry change and
199 * across the morph, so a frame that only just fits the shape at hand would
200 * clip the next one. Leaving room means switching shapes never needs a
201 * camera reset to see what happened.
202 */
203 fitCamera(): void {
204 this.#geometry.computeBoundingSphere();
205 const bs = this.#geometry.boundingSphere;
206 if (!bs) return;
207 const radius = Math.max(bs.radius, 1e-6);
208 const distance = radius * 3.4;
209 this.#controls.target.copy(bs.center);
210 this.#camera.position.set(
211 bs.center.x + distance * 0.55,
212 bs.center.y + distance * 0.35,
213 bs.center.z + distance * 0.75,
214 );
215 this.#camera.near = radius * 0.01;
216 this.#camera.far = radius * 100;
217 this.#camera.updateProjectionMatrix();
218 this.#controls.update();
219 this.#needsRender = true;
220 this.#defaultCameraState = {
221 position: this.#camera.position.clone(),
222 target: this.#controls.target.clone(),
223 };
224 }
226 resetCamera(): void {
227 if (this.#defaultCameraState) {
228 this.#camera.position.copy(this.#defaultCameraState.position);
229 this.#controls.target.copy(this.#defaultCameraState.target);
230 this.#controls.update();
231 this.#needsRender = true;
232 } else {
233 this.fitCamera();
234 }
235 }
237 resize(width: number, height: number): void {
238 // Setting canvas.width clears the canvas even at the same value, which
239 // shows as a blank flash until the next render — skip no-op resizes.
240 if (width === this.#lastW && height === this.#lastH) return;
241 this.#lastW = width;
242 this.#lastH = height;
243 this.#camera.aspect = width / Math.max(1, height);
244 this.#camera.updateProjectionMatrix();
245 // updateStyle=false: the canvas keeps its 100%/100% CSS sizing
246 this.#renderer.setSize(width, height, false);
247 // setSize clears the drawing buffer, so a re-render is required even
248 // though nothing in the scene moved
249 this.#needsRender = true;
250 }
252 /**
253 * Set the drawing buffer to an exact square pixel size, independent of the
254 * container and devicePixelRatio — for capturing at a chosen resolution.
255 * The canvas keeps its CSS sizing, so on screen it just rescales. Undo with
256 * restoreSize().
257 */
258 captureSize(px: number): void {
259 this.#renderer.setPixelRatio(1);
260 this.#renderer.setSize(px, px, false);
261 this.#camera.aspect = 1;
262 this.#camera.updateProjectionMatrix();
263 this.#needsRender = true;
264 }
266 /** Return from captureSize() to the container-driven buffer size. */
267 restoreSize(): void {
268 this.#renderer.setPixelRatio(window.devicePixelRatio || 1);
269 if (this.#lastW > 0 && this.#lastH > 0) {
270 this.#renderer.setSize(this.#lastW, this.#lastH, false);
271 this.#camera.aspect = this.#lastW / Math.max(1, this.#lastH);
272 this.#camera.updateProjectionMatrix();
273 }
274 this.#needsRender = true;
275 }
277 dispose(): void {
278 if (this.#animationId !== null) {
279 cancelAnimationFrame(this.#animationId);
280 this.#animationId = null;
281 }
282 this.#controls.dispose();
283 this.#geometry.dispose();
284 (this.#mesh.material as THREE.Material).dispose();
285 if (this.#renderer.domElement.parentNode) {
286 this.#renderer.domElement.parentNode.removeChild(
287 this.#renderer.domElement,
288 );
289 }
290 this.#renderer.dispose();
291 }
292}