@uppercut-labs/ps1-backgrounds 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +216 -0
- package/NOTICE +24 -0
- package/README.md +159 -0
- package/dist/element.d.ts +12 -0
- package/dist/element.js +115 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +2 -0
- package/dist/kit.d.ts +74 -0
- package/dist/kit.js +383 -0
- package/dist/runtime.d.ts +20 -0
- package/dist/runtime.js +415 -0
- package/dist/scene.d.ts +137 -0
- package/dist/scene.js +10 -0
- package/dist/scenes/generated/atlas-of-little-worlds.d.ts +48 -0
- package/dist/scenes/generated/atlas-of-little-worlds.js +100 -0
- package/dist/scenes/generated/black-ocean-causeway.d.ts +35 -0
- package/dist/scenes/generated/black-ocean-causeway.js +118 -0
- package/dist/scenes/generated/cassette-cathedral.d.ts +35 -0
- package/dist/scenes/generated/cassette-cathedral.js +121 -0
- package/dist/scenes/generated/clockwork-tidelocks.d.ts +35 -0
- package/dist/scenes/generated/clockwork-tidelocks.js +165 -0
- package/dist/scenes/generated/coilwell-descent.d.ts +35 -0
- package/dist/scenes/generated/coilwell-descent.js +156 -0
- package/dist/scenes/generated/copper-fungal-mesa.d.ts +35 -0
- package/dist/scenes/generated/copper-fungal-mesa.js +94 -0
- package/dist/scenes/generated/coral-signal-trench.d.ts +40 -0
- package/dist/scenes/generated/coral-signal-trench.js +136 -0
- package/dist/scenes/generated/counterweight-monastery.d.ts +35 -0
- package/dist/scenes/generated/counterweight-monastery.js +111 -0
- package/dist/scenes/generated/flooded-arcade.d.ts +35 -0
- package/dist/scenes/generated/flooded-arcade.js +144 -0
- package/dist/scenes/generated/fossil-sail-harbor.d.ts +35 -0
- package/dist/scenes/generated/fossil-sail-harbor.js +170 -0
- package/dist/scenes/generated/inverted-orchard.d.ts +35 -0
- package/dist/scenes/generated/inverted-orchard.js +100 -0
- package/dist/scenes/generated/kilnstep-foundry.d.ts +35 -0
- package/dist/scenes/generated/kilnstep-foundry.js +134 -0
- package/dist/scenes/generated/kitebone-viaduct.d.ts +35 -0
- package/dist/scenes/generated/kitebone-viaduct.js +167 -0
- package/dist/scenes/generated/lantern-moth-cloister.d.ts +35 -0
- package/dist/scenes/generated/lantern-moth-cloister.js +126 -0
- package/dist/scenes/generated/mesh-codec.d.ts +21 -0
- package/dist/scenes/generated/mesh-codec.js +46 -0
- package/dist/scenes/generated/midnight-platform-09.d.ts +40 -0
- package/dist/scenes/generated/midnight-platform-09.js +112 -0
- package/dist/scenes/generated/moonseed-conservatory.d.ts +35 -0
- package/dist/scenes/generated/moonseed-conservatory.js +134 -0
- package/dist/scenes/generated/neon-drift-interchange.d.ts +46 -0
- package/dist/scenes/generated/neon-drift-interchange.js +224 -0
- package/dist/scenes/generated/ochre-relay-ruins.d.ts +40 -0
- package/dist/scenes/generated/ochre-relay-ruins.js +111 -0
- package/dist/scenes/generated/paper-moon-parade.d.ts +40 -0
- package/dist/scenes/generated/paper-moon-parade.js +127 -0
- package/dist/scenes/generated/pocket-planet-orchard.d.ts +40 -0
- package/dist/scenes/generated/pocket-planet-orchard.js +90 -0
- package/dist/scenes/generated/prism-quarry.d.ts +35 -0
- package/dist/scenes/generated/prism-quarry.js +94 -0
- package/dist/scenes/generated/radio-observatory.d.ts +35 -0
- package/dist/scenes/generated/radio-observatory.js +120 -0
- package/dist/scenes/generated/signal-freightyard.d.ts +35 -0
- package/dist/scenes/generated/signal-freightyard.js +146 -0
- package/dist/scenes/generated/tape-terraces.d.ts +35 -0
- package/dist/scenes/generated/tape-terraces.js +96 -0
- package/dist/scenes/generated/vector-dojo-showdown.d.ts +46 -0
- package/dist/scenes/generated/vector-dojo-showdown.js +110 -0
- package/package.json +48 -0
package/dist/runtime.js
ADDED
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
import * as THREE from "three";
|
|
2
|
+
import { Kit } from "./kit.js";
|
|
3
|
+
const motionModes = ["ambient", "fixed", "cinematic", "still"];
|
|
4
|
+
const qualityModes = ["auto", "high", "low"];
|
|
5
|
+
const activeHosts = new WeakMap();
|
|
6
|
+
function isMotion(value) { return motionModes.includes(value); }
|
|
7
|
+
function isQuality(value) { return qualityModes.includes(value); }
|
|
8
|
+
function resolveElement(element) {
|
|
9
|
+
if (element === undefined)
|
|
10
|
+
return document.body;
|
|
11
|
+
if (typeof element === "string") {
|
|
12
|
+
const match = document.querySelector(element);
|
|
13
|
+
if (!(match instanceof HTMLElement))
|
|
14
|
+
throw new Error(`Background element not found: ${element}`);
|
|
15
|
+
return match;
|
|
16
|
+
}
|
|
17
|
+
if (!(element instanceof HTMLElement))
|
|
18
|
+
throw new Error("Background element must be an HTMLElement or CSS selector");
|
|
19
|
+
return element;
|
|
20
|
+
}
|
|
21
|
+
function posterFor(scene) {
|
|
22
|
+
if (scene.poster)
|
|
23
|
+
return scene.poster;
|
|
24
|
+
const colors = scene.palette;
|
|
25
|
+
const title = scene.title.replace(/[&<>"']/g, (character) => ({
|
|
26
|
+
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
|
27
|
+
})[character] ?? character);
|
|
28
|
+
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 360" preserveAspectRatio="xMidYMid slice"><defs><linearGradient id="g" x2="0" y2="1"><stop stop-color="${colors.sky}"/><stop offset="1" stop-color="${colors.body}"/></linearGradient></defs><rect width="640" height="360" fill="url(#g)"/><path d="M0 290 110 215 205 260 320 175 430 250 535 190 640 245V360H0Z" fill="${colors.stone}"/><path d="M0 325 140 265 250 310 370 238 505 295 640 260" fill="none" stroke="${colors.accent}" stroke-width="5"/><circle cx="505" cy="105" r="36" fill="${colors.light}"/><text x="26" y="330" fill="${colors.light}" font-family="monospace" font-size="24">${title}</text></svg>`;
|
|
29
|
+
return `data:image/svg+xml,${encodeURIComponent(svg)}`;
|
|
30
|
+
}
|
|
31
|
+
function renderSize(width, height, pixels) {
|
|
32
|
+
const aspect = width / Math.max(height, 1);
|
|
33
|
+
const rawWidth = Math.sqrt(pixels * aspect);
|
|
34
|
+
const rawHeight = rawWidth / aspect;
|
|
35
|
+
const scale = Math.min(1, 960 / rawWidth, 960 / rawHeight);
|
|
36
|
+
return [Math.max(1, Math.round(rawWidth * scale)), Math.max(1, Math.round(rawHeight * scale))];
|
|
37
|
+
}
|
|
38
|
+
function renderTarget() {
|
|
39
|
+
return new THREE.WebGLRenderTarget(384, 216, {
|
|
40
|
+
magFilter: THREE.NearestFilter, minFilter: THREE.NearestFilter, depthBuffer: true,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
const vertexShader = `varying vec2 vUv;
|
|
44
|
+
void main() { vUv = uv; gl_Position = vec4(position.xy, 0.0, 1.0); }`;
|
|
45
|
+
const fragmentShader = `uniform sampler2D sceneTex;
|
|
46
|
+
uniform float ps1;
|
|
47
|
+
varying vec2 vUv;
|
|
48
|
+
float dither(vec2 p) {
|
|
49
|
+
vec2 c = mod(floor(p), 4.0);
|
|
50
|
+
if (c.y < 1.0) return c.x < 1.0 ? -4.0 : c.x < 2.0 ? 0.0 : c.x < 3.0 ? -3.0 : 1.0;
|
|
51
|
+
if (c.y < 2.0) return c.x < 1.0 ? 2.0 : c.x < 2.0 ? -2.0 : c.x < 3.0 ? 3.0 : -1.0;
|
|
52
|
+
if (c.y < 3.0) return c.x < 1.0 ? -3.0 : c.x < 2.0 ? 1.0 : c.x < 3.0 ? -4.0 : 0.0;
|
|
53
|
+
return c.x < 1.0 ? 3.0 : c.x < 2.0 ? -1.0 : c.x < 3.0 ? 2.0 : -2.0;
|
|
54
|
+
}
|
|
55
|
+
void main() {
|
|
56
|
+
vec3 color = texture2D(sceneTex, vUv).rgb;
|
|
57
|
+
color = mix(12.92 * color, 1.055 * pow(max(color, vec3(0.0)), vec3(1.0 / 2.4)) - 0.055,
|
|
58
|
+
step(vec3(0.0031308), color));
|
|
59
|
+
if (ps1 > 0.5) color = floor(clamp(color * 255.0 + dither(gl_FragCoord.xy), 0.0, 255.0) / 8.0) / 31.0;
|
|
60
|
+
gl_FragColor = vec4(color, 1.0);
|
|
61
|
+
}`;
|
|
62
|
+
function makeCompositeMaterial() {
|
|
63
|
+
return new THREE.ShaderMaterial({
|
|
64
|
+
uniforms: { sceneTex: { value: null }, ps1: { value: 1 } },
|
|
65
|
+
vertexShader, fragmentShader, depthTest: false, depthWrite: false,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
function interpolate(a, b, amount) {
|
|
69
|
+
return new THREE.Vector3(...a).lerp(new THREE.Vector3(...b), amount);
|
|
70
|
+
}
|
|
71
|
+
function cameraAmount(mode, seconds, speed) {
|
|
72
|
+
if (mode === "fixed" || mode === "still")
|
|
73
|
+
return 0;
|
|
74
|
+
if (mode === "ambient")
|
|
75
|
+
return 0.5 - 0.5 * Math.cos(seconds * speed * Math.PI / 20);
|
|
76
|
+
const frame = ((seconds * speed) % 8 + 8) % 8;
|
|
77
|
+
const u = frame / 8;
|
|
78
|
+
return u * u * (3 - 2 * u);
|
|
79
|
+
}
|
|
80
|
+
class Background {
|
|
81
|
+
canvas = document.createElement("canvas");
|
|
82
|
+
host;
|
|
83
|
+
wrapper = document.createElement("div");
|
|
84
|
+
poster = document.createElement("img");
|
|
85
|
+
camera = new THREE.PerspectiveCamera(50, 16 / 9, 0.1, 300);
|
|
86
|
+
compositeScene = new THREE.Scene();
|
|
87
|
+
compositeCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
|
|
88
|
+
compositeMaterial = makeCompositeMaterial();
|
|
89
|
+
reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
|
|
90
|
+
resizeObserver;
|
|
91
|
+
intersectionObserver;
|
|
92
|
+
kit;
|
|
93
|
+
definition;
|
|
94
|
+
renderer;
|
|
95
|
+
target;
|
|
96
|
+
mode;
|
|
97
|
+
quality;
|
|
98
|
+
autoLow = false;
|
|
99
|
+
shot;
|
|
100
|
+
speed;
|
|
101
|
+
originalPosition;
|
|
102
|
+
changedPosition = false;
|
|
103
|
+
visible = true;
|
|
104
|
+
contextLost = false;
|
|
105
|
+
frameId = 0;
|
|
106
|
+
lastFrame = 0;
|
|
107
|
+
elapsed = 0;
|
|
108
|
+
slowFrames = 0;
|
|
109
|
+
currentState = "paused";
|
|
110
|
+
get state() { return this.currentState; }
|
|
111
|
+
constructor(options) {
|
|
112
|
+
if (!options.scene || typeof options.scene.build !== "function")
|
|
113
|
+
throw new Error("A valid scene is required");
|
|
114
|
+
this.definition = options.scene;
|
|
115
|
+
this.mode = options.motion ?? "ambient";
|
|
116
|
+
this.quality = options.quality ?? "auto";
|
|
117
|
+
if (!isMotion(this.mode))
|
|
118
|
+
throw new Error(`Unknown motion mode: ${this.mode}`);
|
|
119
|
+
if (!isQuality(this.quality))
|
|
120
|
+
throw new Error(`Unknown quality mode: ${this.quality}`);
|
|
121
|
+
this.shot = options.camera ?? "survey";
|
|
122
|
+
if (!options.scene.cameras[this.shot])
|
|
123
|
+
throw new Error(`Unknown camera: ${this.shot}`);
|
|
124
|
+
this.speed = options.speed ?? 1;
|
|
125
|
+
if (!Number.isFinite(this.speed) || this.speed <= 0)
|
|
126
|
+
throw new Error("Background speed must be positive");
|
|
127
|
+
this.host = resolveElement(options.element);
|
|
128
|
+
this.originalPosition = this.host.style.position;
|
|
129
|
+
if (this.host !== document.body && getComputedStyle(this.host).position === "static") {
|
|
130
|
+
this.host.style.position = "relative";
|
|
131
|
+
this.changedPosition = true;
|
|
132
|
+
}
|
|
133
|
+
this.wrapper.style.cssText = this.host === document.body
|
|
134
|
+
? "position:fixed;inset:0;z-index:-1;overflow:hidden;pointer-events:none;"
|
|
135
|
+
: "position:absolute;inset:0;z-index:0;overflow:hidden;pointer-events:none;";
|
|
136
|
+
this.wrapper.setAttribute("aria-hidden", "true");
|
|
137
|
+
this.canvas.style.cssText = "display:block;width:100%;height:100%;image-rendering:pixelated;pointer-events:none;";
|
|
138
|
+
this.canvas.setAttribute("aria-hidden", "true");
|
|
139
|
+
this.poster.style.cssText = "position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:none;pointer-events:none;";
|
|
140
|
+
this.poster.alt = "";
|
|
141
|
+
this.poster.src = posterFor(options.scene);
|
|
142
|
+
this.wrapper.append(this.canvas, this.poster);
|
|
143
|
+
this.host.prepend(this.wrapper);
|
|
144
|
+
this.kit = this.makeKit();
|
|
145
|
+
try {
|
|
146
|
+
this.renderer = new THREE.WebGLRenderer({ canvas: this.canvas, antialias: false, powerPreference: "low-power" });
|
|
147
|
+
this.renderer.setPixelRatio(1);
|
|
148
|
+
this.renderer.outputColorSpace = THREE.SRGBColorSpace;
|
|
149
|
+
this.renderer.toneMapping = THREE.NoToneMapping;
|
|
150
|
+
this.target = renderTarget();
|
|
151
|
+
this.compositeMaterial.uniforms.sceneTex.value = this.target.texture;
|
|
152
|
+
this.compositeScene.add(new THREE.Mesh(new THREE.PlaneGeometry(2, 2), this.compositeMaterial));
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
this.showFallback(error);
|
|
156
|
+
}
|
|
157
|
+
if (this.renderer) {
|
|
158
|
+
try {
|
|
159
|
+
options.scene.build(this.kit);
|
|
160
|
+
}
|
|
161
|
+
catch (error) {
|
|
162
|
+
this.kit.dispose();
|
|
163
|
+
this.target?.dispose();
|
|
164
|
+
this.compositeMaterial.dispose();
|
|
165
|
+
this.renderer.dispose();
|
|
166
|
+
this.wrapper.remove();
|
|
167
|
+
if (this.changedPosition)
|
|
168
|
+
this.host.style.position = this.originalPosition;
|
|
169
|
+
throw error;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
this.resizeObserver = new ResizeObserver(() => this.resize());
|
|
173
|
+
this.resizeObserver.observe(this.host);
|
|
174
|
+
if (typeof IntersectionObserver !== "undefined") {
|
|
175
|
+
this.intersectionObserver = new IntersectionObserver((entries) => {
|
|
176
|
+
this.visible = entries[0]?.isIntersecting ?? false;
|
|
177
|
+
this.schedule();
|
|
178
|
+
});
|
|
179
|
+
this.intersectionObserver.observe(this.host);
|
|
180
|
+
}
|
|
181
|
+
this.reducedMotion.addEventListener("change", this.onVisibility);
|
|
182
|
+
document.addEventListener("visibilitychange", this.onVisibility);
|
|
183
|
+
this.canvas.addEventListener("webglcontextlost", this.onContextLost);
|
|
184
|
+
this.canvas.addEventListener("webglcontextrestored", this.onContextRestored);
|
|
185
|
+
this.resize();
|
|
186
|
+
this.schedule();
|
|
187
|
+
}
|
|
188
|
+
makeKit() {
|
|
189
|
+
const kit = new Kit(this.definition, () => {
|
|
190
|
+
if (this.kit === kit && this.currentState === "paused" && this.visible && !document.hidden)
|
|
191
|
+
this.draw();
|
|
192
|
+
}, (error) => {
|
|
193
|
+
if (this.kit === kit && this.currentState !== "disposed")
|
|
194
|
+
this.showFallback(error);
|
|
195
|
+
});
|
|
196
|
+
return kit;
|
|
197
|
+
}
|
|
198
|
+
showFallback(error) {
|
|
199
|
+
this.currentState = "fallback";
|
|
200
|
+
this.poster.style.display = "block";
|
|
201
|
+
const detail = error instanceof Error ? error : new Error(String(error));
|
|
202
|
+
this.host.dispatchEvent(new CustomEvent("ps1-background-error", {
|
|
203
|
+
detail, bubbles: true, composed: true,
|
|
204
|
+
}));
|
|
205
|
+
console.error("PS1 Backgrounds could not render this scene:", detail);
|
|
206
|
+
}
|
|
207
|
+
onVisibility = () => { this.schedule(); };
|
|
208
|
+
onContextLost = (event) => {
|
|
209
|
+
event.preventDefault();
|
|
210
|
+
this.contextLost = true;
|
|
211
|
+
this.kit.dispose();
|
|
212
|
+
this.target?.dispose();
|
|
213
|
+
this.target = undefined;
|
|
214
|
+
for (const child of [...this.compositeScene.children]) {
|
|
215
|
+
if (child instanceof THREE.Mesh)
|
|
216
|
+
child.geometry.dispose();
|
|
217
|
+
this.compositeScene.remove(child);
|
|
218
|
+
}
|
|
219
|
+
this.compositeMaterial.dispose();
|
|
220
|
+
this.compositeMaterial = makeCompositeMaterial();
|
|
221
|
+
this.showFallback(new Error("WebGL context lost"));
|
|
222
|
+
this.schedule();
|
|
223
|
+
};
|
|
224
|
+
onContextRestored = () => {
|
|
225
|
+
this.contextLost = false;
|
|
226
|
+
const kit = this.makeKit();
|
|
227
|
+
try {
|
|
228
|
+
this.definition.build(kit);
|
|
229
|
+
}
|
|
230
|
+
catch (error) {
|
|
231
|
+
kit.dispose();
|
|
232
|
+
this.showFallback(error);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
this.kit = kit;
|
|
236
|
+
this.target = renderTarget();
|
|
237
|
+
this.compositeMaterial.uniforms.sceneTex.value = this.target.texture;
|
|
238
|
+
this.compositeScene.add(new THREE.Mesh(new THREE.PlaneGeometry(2, 2), this.compositeMaterial));
|
|
239
|
+
this.poster.style.display = "none";
|
|
240
|
+
this.currentState = "paused";
|
|
241
|
+
this.resize();
|
|
242
|
+
this.schedule();
|
|
243
|
+
this.host.dispatchEvent(new CustomEvent("ps1-background-restored", {
|
|
244
|
+
bubbles: true, composed: true,
|
|
245
|
+
}));
|
|
246
|
+
};
|
|
247
|
+
resize() {
|
|
248
|
+
if (!this.renderer || !this.target || this.currentState === "disposed")
|
|
249
|
+
return;
|
|
250
|
+
const width = this.host === document.body ? window.innerWidth : this.host.clientWidth;
|
|
251
|
+
const height = this.host === document.body ? window.innerHeight : this.host.clientHeight;
|
|
252
|
+
if (width < 1 || height < 1)
|
|
253
|
+
return;
|
|
254
|
+
const authored = this.kit.definition.presentation?.internal_size;
|
|
255
|
+
const authoredPixels = authored ? authored[0] * authored[1] : 384 * 216;
|
|
256
|
+
const maxPixels = this.quality === "low" || (this.quality === "auto" && this.autoLow)
|
|
257
|
+
? 320 * 180
|
|
258
|
+
: this.quality === "high" ? authoredPixels : Math.min(authoredPixels, 640 * 360);
|
|
259
|
+
const [renderWidth, renderHeight] = renderSize(width, height, maxPixels);
|
|
260
|
+
this.renderer.setSize(renderWidth, renderHeight, false);
|
|
261
|
+
this.target.setSize(renderWidth, renderHeight);
|
|
262
|
+
this.camera.aspect = renderWidth / renderHeight;
|
|
263
|
+
this.camera.updateProjectionMatrix();
|
|
264
|
+
if (this.mode === "still" || this.reducedMotion.matches)
|
|
265
|
+
this.draw();
|
|
266
|
+
}
|
|
267
|
+
updateCamera(path, amount) {
|
|
268
|
+
const eased = amount * amount * (3 - 2 * amount);
|
|
269
|
+
if (path.projection === "orthogonal") {
|
|
270
|
+
const height = path.size ?? 30;
|
|
271
|
+
const width = height * this.camera.aspect;
|
|
272
|
+
if (!(this.compositionCamera instanceof THREE.OrthographicCamera)) {
|
|
273
|
+
this.compositionCamera = new THREE.OrthographicCamera(-width / 2, width / 2, height / 2, -height / 2, 0.1, 300);
|
|
274
|
+
}
|
|
275
|
+
const active = this.compositionCamera;
|
|
276
|
+
active.left = -width / 2;
|
|
277
|
+
active.right = width / 2;
|
|
278
|
+
active.top = height / 2;
|
|
279
|
+
active.bottom = -height / 2;
|
|
280
|
+
active.position.copy(interpolate(path.from, path.to, eased));
|
|
281
|
+
active.lookAt(interpolate(path.target_from, path.target_to, eased));
|
|
282
|
+
active.updateProjectionMatrix();
|
|
283
|
+
}
|
|
284
|
+
else {
|
|
285
|
+
this.compositionCamera = this.camera;
|
|
286
|
+
this.camera.position.copy(interpolate(path.from, path.to, eased));
|
|
287
|
+
this.camera.lookAt(interpolate(path.target_from, path.target_to, eased));
|
|
288
|
+
this.camera.fov = path.fov ?? 50;
|
|
289
|
+
this.camera.updateProjectionMatrix();
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
compositionCamera = this.camera;
|
|
293
|
+
draw() {
|
|
294
|
+
if (!this.renderer || !this.target || this.contextLost || this.currentState === "disposed")
|
|
295
|
+
return;
|
|
296
|
+
const mode = this.reducedMotion.matches ? "still" : this.mode;
|
|
297
|
+
const seconds = mode === "still" ? 0 : this.elapsed;
|
|
298
|
+
const amount = cameraAmount(mode, seconds, this.speed);
|
|
299
|
+
this.updateCamera(this.kit.definition.cameras[this.shot], amount);
|
|
300
|
+
this.kit.setTime(seconds);
|
|
301
|
+
this.compositeMaterial.uniforms.ps1.value = this.kit.definition.presentation?.composite === "clean" ? 0 : 1;
|
|
302
|
+
try {
|
|
303
|
+
this.renderer.setRenderTarget(this.target);
|
|
304
|
+
this.renderer.render(this.kit.scene, this.compositionCamera);
|
|
305
|
+
this.renderer.setRenderTarget(null);
|
|
306
|
+
this.renderer.render(this.compositeScene, this.compositeCamera);
|
|
307
|
+
}
|
|
308
|
+
catch (error) {
|
|
309
|
+
this.showFallback(error);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
tick = (now) => {
|
|
313
|
+
this.frameId = 0;
|
|
314
|
+
if (!this.canRun()) {
|
|
315
|
+
this.schedule();
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
const previous = this.lastFrame;
|
|
319
|
+
const delta = previous ? Math.min(100, now - previous) : 0;
|
|
320
|
+
const cap = this.quality === "low" || this.autoLow ? 30 : 60;
|
|
321
|
+
if (previous && delta < 1000 / cap - 1) {
|
|
322
|
+
this.frameId = requestAnimationFrame(this.tick);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
this.lastFrame = now;
|
|
326
|
+
this.elapsed += delta / 1000;
|
|
327
|
+
if (this.quality === "auto" && !this.autoLow && delta > 45) {
|
|
328
|
+
this.slowFrames++;
|
|
329
|
+
if (this.slowFrames >= 20) {
|
|
330
|
+
this.autoLow = true;
|
|
331
|
+
this.resize();
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
else if (delta < 35) {
|
|
335
|
+
this.slowFrames = 0;
|
|
336
|
+
}
|
|
337
|
+
this.draw();
|
|
338
|
+
this.frameId = requestAnimationFrame(this.tick);
|
|
339
|
+
};
|
|
340
|
+
canRun() {
|
|
341
|
+
return Boolean(this.renderer && !this.contextLost && this.visible && !document.hidden &&
|
|
342
|
+
!this.reducedMotion.matches && this.mode !== "still" && this.currentState !== "disposed" &&
|
|
343
|
+
this.currentState !== "fallback");
|
|
344
|
+
}
|
|
345
|
+
schedule() {
|
|
346
|
+
if (this.frameId) {
|
|
347
|
+
cancelAnimationFrame(this.frameId);
|
|
348
|
+
this.frameId = 0;
|
|
349
|
+
}
|
|
350
|
+
if (this.currentState === "disposed" || this.currentState === "fallback")
|
|
351
|
+
return;
|
|
352
|
+
if (this.canRun()) {
|
|
353
|
+
this.currentState = "running";
|
|
354
|
+
this.lastFrame = 0;
|
|
355
|
+
this.frameId = requestAnimationFrame(this.tick);
|
|
356
|
+
}
|
|
357
|
+
else {
|
|
358
|
+
this.currentState = "paused";
|
|
359
|
+
if (this.renderer && !this.contextLost && this.visible && !document.hidden)
|
|
360
|
+
this.draw();
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
setMotion(mode) {
|
|
364
|
+
if (this.currentState === "disposed")
|
|
365
|
+
throw new Error("Background is disposed");
|
|
366
|
+
if (!isMotion(mode))
|
|
367
|
+
throw new Error(`Unknown motion mode: ${mode}`);
|
|
368
|
+
this.mode = mode;
|
|
369
|
+
this.schedule();
|
|
370
|
+
}
|
|
371
|
+
setQuality(mode) {
|
|
372
|
+
if (this.currentState === "disposed")
|
|
373
|
+
throw new Error("Background is disposed");
|
|
374
|
+
if (!isQuality(mode))
|
|
375
|
+
throw new Error(`Unknown quality mode: ${mode}`);
|
|
376
|
+
this.quality = mode;
|
|
377
|
+
this.autoLow = false;
|
|
378
|
+
this.resize();
|
|
379
|
+
this.schedule();
|
|
380
|
+
}
|
|
381
|
+
dispose() {
|
|
382
|
+
if (this.currentState === "disposed")
|
|
383
|
+
return;
|
|
384
|
+
this.currentState = "disposed";
|
|
385
|
+
if (activeHosts.get(this.host) === this)
|
|
386
|
+
activeHosts.delete(this.host);
|
|
387
|
+
if (this.frameId)
|
|
388
|
+
cancelAnimationFrame(this.frameId);
|
|
389
|
+
this.resizeObserver.disconnect();
|
|
390
|
+
this.intersectionObserver?.disconnect();
|
|
391
|
+
this.reducedMotion.removeEventListener("change", this.onVisibility);
|
|
392
|
+
document.removeEventListener("visibilitychange", this.onVisibility);
|
|
393
|
+
this.canvas.removeEventListener("webglcontextlost", this.onContextLost);
|
|
394
|
+
this.canvas.removeEventListener("webglcontextrestored", this.onContextRestored);
|
|
395
|
+
this.kit.dispose();
|
|
396
|
+
this.target?.dispose();
|
|
397
|
+
for (const child of this.compositeScene.children) {
|
|
398
|
+
if (child instanceof THREE.Mesh)
|
|
399
|
+
child.geometry.dispose();
|
|
400
|
+
}
|
|
401
|
+
this.compositeMaterial.dispose();
|
|
402
|
+
this.renderer?.dispose();
|
|
403
|
+
this.wrapper.remove();
|
|
404
|
+
if (this.changedPosition)
|
|
405
|
+
this.host.style.position = this.originalPosition;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
export function createBackground(options) {
|
|
409
|
+
const host = resolveElement(options.element);
|
|
410
|
+
if (activeHosts.has(host))
|
|
411
|
+
throw new Error("Background element already has an active scene");
|
|
412
|
+
const background = new Background({ ...options, element: host });
|
|
413
|
+
activeHosts.set(host, background);
|
|
414
|
+
return background;
|
|
415
|
+
}
|
package/dist/scene.d.ts
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
export type Vec2 = readonly [number, number];
|
|
2
|
+
export type Vec3 = readonly [number, number, number];
|
|
3
|
+
export type Quat = readonly [number, number, number, number];
|
|
4
|
+
export type Role = "body" | "accent" | "stone" | "metal" | "light" | "water";
|
|
5
|
+
export interface ScenePalette extends Record<Role, string> {
|
|
6
|
+
sky: string;
|
|
7
|
+
fog: string;
|
|
8
|
+
}
|
|
9
|
+
export interface CameraPath {
|
|
10
|
+
from: Vec3;
|
|
11
|
+
to: Vec3;
|
|
12
|
+
target_from: Vec3;
|
|
13
|
+
target_to: Vec3;
|
|
14
|
+
fov?: number;
|
|
15
|
+
projection?: "perspective" | "orthogonal";
|
|
16
|
+
size?: number;
|
|
17
|
+
}
|
|
18
|
+
export interface PresentationSpec {
|
|
19
|
+
internal_size?: Vec2;
|
|
20
|
+
composite?: "ps1" | "clean";
|
|
21
|
+
surface?: "legacy" | "painted";
|
|
22
|
+
snap_vertices?: boolean;
|
|
23
|
+
affine_uv?: boolean;
|
|
24
|
+
}
|
|
25
|
+
export interface TransformRecord {
|
|
26
|
+
position: Vec3;
|
|
27
|
+
quaternion: Quat;
|
|
28
|
+
scale: Vec3;
|
|
29
|
+
}
|
|
30
|
+
export interface MotionRecord {
|
|
31
|
+
times: number[];
|
|
32
|
+
positions: Vec3[];
|
|
33
|
+
quaternions: Quat[];
|
|
34
|
+
scales: Vec3[];
|
|
35
|
+
visible: boolean[];
|
|
36
|
+
}
|
|
37
|
+
export interface MaterialRecord {
|
|
38
|
+
role?: Role;
|
|
39
|
+
kind: "legacy" | "painted" | "card" | "cutout";
|
|
40
|
+
color?: string;
|
|
41
|
+
opacity?: number;
|
|
42
|
+
texture?: string;
|
|
43
|
+
blob?: boolean;
|
|
44
|
+
}
|
|
45
|
+
export type PrimitiveKind = "box" | "cylinder" | "sphere" | "torus" | "quad";
|
|
46
|
+
export interface PrimitiveRecord {
|
|
47
|
+
kind: PrimitiveKind;
|
|
48
|
+
args: (number | number[])[];
|
|
49
|
+
transform: TransformRecord;
|
|
50
|
+
visible?: boolean;
|
|
51
|
+
material: MaterialRecord;
|
|
52
|
+
motion?: MotionRecord;
|
|
53
|
+
}
|
|
54
|
+
export interface MeshRecord {
|
|
55
|
+
kind?: "mesh";
|
|
56
|
+
positions: number[];
|
|
57
|
+
normals?: number[];
|
|
58
|
+
uvs?: number[];
|
|
59
|
+
colors?: number[];
|
|
60
|
+
indices?: number[];
|
|
61
|
+
transform: TransformRecord;
|
|
62
|
+
visible?: boolean;
|
|
63
|
+
material: MaterialRecord;
|
|
64
|
+
motion?: MotionRecord;
|
|
65
|
+
}
|
|
66
|
+
export interface SceneNode {
|
|
67
|
+
readonly id: number;
|
|
68
|
+
}
|
|
69
|
+
export interface SceneKit {
|
|
70
|
+
box(options: {
|
|
71
|
+
position: Vec3;
|
|
72
|
+
size: Vec3;
|
|
73
|
+
material: Role;
|
|
74
|
+
rotation?: Vec3;
|
|
75
|
+
}): SceneNode;
|
|
76
|
+
cylinder(options: {
|
|
77
|
+
position: Vec3;
|
|
78
|
+
radius: number;
|
|
79
|
+
height: number;
|
|
80
|
+
material: Role;
|
|
81
|
+
segments?: number;
|
|
82
|
+
topRadius?: number;
|
|
83
|
+
}): SceneNode;
|
|
84
|
+
sphere(options: {
|
|
85
|
+
position: Vec3;
|
|
86
|
+
radius: number;
|
|
87
|
+
material: Role;
|
|
88
|
+
rings?: number;
|
|
89
|
+
segments?: number;
|
|
90
|
+
}): SceneNode;
|
|
91
|
+
torus(options: {
|
|
92
|
+
position: Vec3;
|
|
93
|
+
inner: number;
|
|
94
|
+
outer: number;
|
|
95
|
+
material: Role;
|
|
96
|
+
rotation?: Vec3;
|
|
97
|
+
}): SceneNode;
|
|
98
|
+
beam(options: {
|
|
99
|
+
from: Vec3;
|
|
100
|
+
to: Vec3;
|
|
101
|
+
width: number;
|
|
102
|
+
material: Role;
|
|
103
|
+
}): SceneNode;
|
|
104
|
+
island(options: {
|
|
105
|
+
position: Vec3;
|
|
106
|
+
radius: number;
|
|
107
|
+
height?: number;
|
|
108
|
+
depth?: number;
|
|
109
|
+
phase?: Vec2;
|
|
110
|
+
}): SceneNode;
|
|
111
|
+
ribbonArch(options: {
|
|
112
|
+
position: Vec3;
|
|
113
|
+
radius: number;
|
|
114
|
+
width: number;
|
|
115
|
+
material: Role;
|
|
116
|
+
}): SceneNode[];
|
|
117
|
+
rotate(node: SceneNode, options: {
|
|
118
|
+
axis: Vec3;
|
|
119
|
+
speed: number;
|
|
120
|
+
}): void;
|
|
121
|
+
addPrimitive(record: PrimitiveRecord): SceneNode;
|
|
122
|
+
addMesh(record: MeshRecord): SceneNode;
|
|
123
|
+
}
|
|
124
|
+
export interface SceneDefinition {
|
|
125
|
+
id: string;
|
|
126
|
+
title: string;
|
|
127
|
+
palette: ScenePalette;
|
|
128
|
+
fog: Vec2;
|
|
129
|
+
cameras: {
|
|
130
|
+
survey: CameraPath;
|
|
131
|
+
detail: CameraPath;
|
|
132
|
+
};
|
|
133
|
+
presentation?: PresentationSpec;
|
|
134
|
+
poster?: string;
|
|
135
|
+
build(kit: SceneKit): void;
|
|
136
|
+
}
|
|
137
|
+
export declare function defineScene<T extends SceneDefinition>(definition: T): T;
|
package/dist/scene.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export function defineScene(definition) {
|
|
2
|
+
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(definition.id)) {
|
|
3
|
+
throw new Error(`Invalid scene id: ${definition.id}`);
|
|
4
|
+
}
|
|
5
|
+
for (const shot of ["survey", "detail"]) {
|
|
6
|
+
if (!definition.cameras[shot])
|
|
7
|
+
throw new Error(`Scene ${definition.id} has no ${shot} camera`);
|
|
8
|
+
}
|
|
9
|
+
return definition;
|
|
10
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
declare const _default: {
|
|
2
|
+
title: string;
|
|
3
|
+
palette: {
|
|
4
|
+
accent: string;
|
|
5
|
+
body: string;
|
|
6
|
+
fog: string;
|
|
7
|
+
light: string;
|
|
8
|
+
metal: string;
|
|
9
|
+
sky: string;
|
|
10
|
+
stone: string;
|
|
11
|
+
water: string;
|
|
12
|
+
};
|
|
13
|
+
fog: [number, number];
|
|
14
|
+
cameras: {
|
|
15
|
+
detail: {
|
|
16
|
+
fov: number;
|
|
17
|
+
from: [number, number, number];
|
|
18
|
+
target_from: [number, number, number];
|
|
19
|
+
target_to: [number, number, number];
|
|
20
|
+
to: [number, number, number];
|
|
21
|
+
};
|
|
22
|
+
survey: {
|
|
23
|
+
fov: number;
|
|
24
|
+
from: [number, number, number];
|
|
25
|
+
projection: "orthogonal";
|
|
26
|
+
size: number;
|
|
27
|
+
target_from: [number, number, number];
|
|
28
|
+
target_to: [number, number, number];
|
|
29
|
+
to: [number, number, number];
|
|
30
|
+
};
|
|
31
|
+
};
|
|
32
|
+
id: string;
|
|
33
|
+
poster: string;
|
|
34
|
+
seed: number;
|
|
35
|
+
presentation: {
|
|
36
|
+
composite: "clean";
|
|
37
|
+
internal_size: [number, number];
|
|
38
|
+
surface: "painted";
|
|
39
|
+
};
|
|
40
|
+
overlay: {
|
|
41
|
+
accent: string;
|
|
42
|
+
style: string;
|
|
43
|
+
subtitle: string;
|
|
44
|
+
title: string;
|
|
45
|
+
};
|
|
46
|
+
build(kit: import("../../scene.js").SceneKit): void;
|
|
47
|
+
};
|
|
48
|
+
export default _default;
|