sceneview-web 1.2.0 → 1.5.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/filament/filament.js +1411 -0
- package/filament/filament.wasm +0 -0
- package/package.json +1 -1
- package/sceneview.js +383 -181
package/sceneview.js
CHANGED
|
@@ -4,51 +4,26 @@
|
|
|
4
4
|
* One line to render a 3D model:
|
|
5
5
|
* SceneView.modelViewer("canvas", "model.glb")
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
* Just include one script:
|
|
9
|
-
* <script src="js/sceneview.js"></script>
|
|
10
|
-
*
|
|
11
|
-
* Powered by Filament.js (Google's PBR renderer, WASM).
|
|
7
|
+
* Powered by Filament.js v1.70.1 (Google's PBR renderer, WASM).
|
|
12
8
|
* https://sceneview.github.io
|
|
13
9
|
*
|
|
14
|
-
* @version 1.
|
|
10
|
+
* @version 1.5.0
|
|
15
11
|
* @license MIT
|
|
16
12
|
*/
|
|
17
13
|
(function(global) {
|
|
18
14
|
'use strict';
|
|
19
15
|
|
|
20
|
-
var FILAMENT_CDN = 'https://cdn.jsdelivr.net/npm/filament@1.52.3/filament.js';
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Load Filament.js CDN dynamically if not already present.
|
|
24
|
-
* Returns a Promise that resolves when the Filament global is available.
|
|
25
|
-
*/
|
|
26
16
|
function _ensureFilament() {
|
|
27
17
|
return new Promise(function(resolve, reject) {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
var existing = document.querySelector('script[src*="filament"]');
|
|
35
|
-
if (existing) {
|
|
36
|
-
existing.addEventListener('load', function() { resolve(); });
|
|
37
|
-
existing.addEventListener('error', function() { reject(new Error('SceneView: Failed to load Filament.js from CDN')); });
|
|
38
|
-
return;
|
|
39
|
-
}
|
|
40
|
-
// Inject script tag
|
|
41
|
-
var script = document.createElement('script');
|
|
42
|
-
script.src = FILAMENT_CDN;
|
|
43
|
-
script.onload = function() { resolve(); };
|
|
44
|
-
script.onerror = function() { reject(new Error('SceneView: Failed to load Filament.js from CDN (' + FILAMENT_CDN + ')')); };
|
|
45
|
-
document.head.appendChild(script);
|
|
18
|
+
if (typeof Filament !== 'undefined') { resolve(); return; }
|
|
19
|
+
var attempts = 0;
|
|
20
|
+
var check = setInterval(function() {
|
|
21
|
+
if (typeof Filament !== 'undefined') { clearInterval(check); resolve(); }
|
|
22
|
+
if (++attempts > 100) { clearInterval(check); reject(new Error('SceneView: Filament.js not loaded')); }
|
|
23
|
+
}, 50);
|
|
46
24
|
});
|
|
47
25
|
}
|
|
48
26
|
|
|
49
|
-
/**
|
|
50
|
-
* SceneView instance — wraps Filament engine, scene, camera, renderer.
|
|
51
|
-
*/
|
|
52
27
|
class SceneViewInstance {
|
|
53
28
|
constructor(canvas, engine, scene, renderer, view, swapChain, camera, cameraEntity, loader) {
|
|
54
29
|
this._canvas = canvas;
|
|
@@ -61,7 +36,7 @@
|
|
|
61
36
|
this._cameraEntity = cameraEntity;
|
|
62
37
|
this._loader = loader;
|
|
63
38
|
this._asset = null;
|
|
64
|
-
this._angle = 0;
|
|
39
|
+
this._angle = 0.785;
|
|
65
40
|
this._autoRotate = true;
|
|
66
41
|
this._orbitRadius = 3.5;
|
|
67
42
|
this._orbitHeight = 0.8;
|
|
@@ -69,158 +44,366 @@
|
|
|
69
44
|
this._running = true;
|
|
70
45
|
this._isDragging = false;
|
|
71
46
|
this._lastMouse = { x: 0, y: 0 };
|
|
47
|
+
this._velocityAngle = 0;
|
|
48
|
+
this._velocityHeight = 0;
|
|
49
|
+
this._dampingFactor = 0.95;
|
|
50
|
+
this._wantsAutoRotate = true;
|
|
51
|
+
this._autoRotateTimer = null;
|
|
52
|
+
this._userLights = [];
|
|
72
53
|
this._setupControls();
|
|
73
54
|
this._setupResizeObserver();
|
|
74
55
|
this._startRenderLoop();
|
|
75
56
|
}
|
|
76
57
|
|
|
77
|
-
|
|
58
|
+
// ── Model loading ──
|
|
59
|
+
|
|
78
60
|
loadModel(url) {
|
|
79
61
|
var self = this;
|
|
80
62
|
return new Promise(function(resolve, reject) {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
return;
|
|
90
|
-
}
|
|
91
|
-
// Fetch via Filament.init with asset — this always fires the callback
|
|
92
|
-
// because it needs to fetch the asset even if WASM is already loaded
|
|
93
|
-
Filament.init([url], function() {
|
|
94
|
-
try {
|
|
95
|
-
self._showModel(url);
|
|
96
|
-
resolve(self);
|
|
97
|
-
} catch (e) {
|
|
98
|
-
reject(e);
|
|
99
|
-
}
|
|
100
|
-
});
|
|
63
|
+
fetch(url)
|
|
64
|
+
.then(function(resp) { return resp.arrayBuffer(); })
|
|
65
|
+
.then(function(buffer) {
|
|
66
|
+
Filament.assets = Filament.assets || {};
|
|
67
|
+
Filament.assets[url] = new Uint8Array(buffer);
|
|
68
|
+
try { self._showModel(url); resolve(self); } catch (e) { reject(e); }
|
|
69
|
+
})
|
|
70
|
+
.catch(reject);
|
|
101
71
|
});
|
|
102
72
|
}
|
|
103
73
|
|
|
104
74
|
_showModel(url) {
|
|
105
|
-
// Remove previous model
|
|
106
75
|
if (this._asset) {
|
|
107
|
-
|
|
108
|
-
|
|
76
|
+
try {
|
|
77
|
+
this._asset.getRenderableEntities().forEach(function(e) { this._scene.remove(e); }.bind(this));
|
|
78
|
+
this._scene.remove(this._asset.getRoot());
|
|
79
|
+
} catch (e) {}
|
|
109
80
|
this._asset = null;
|
|
110
81
|
}
|
|
111
|
-
|
|
112
82
|
var data = Filament.assets[url];
|
|
113
83
|
if (!data) throw new Error('Failed to fetch model: ' + url);
|
|
114
|
-
|
|
115
84
|
var asset = this._loader.createAsset(data);
|
|
116
85
|
if (!asset) throw new Error('Failed to parse model: ' + url);
|
|
117
|
-
|
|
118
86
|
asset.loadResources();
|
|
119
87
|
this._scene.addEntity(asset.getRoot());
|
|
120
88
|
this._scene.addEntities(asset.getRenderableEntities());
|
|
121
89
|
this._asset = asset;
|
|
90
|
+
this._autoFrame(asset);
|
|
91
|
+
}
|
|
122
92
|
|
|
123
|
-
|
|
93
|
+
_autoFrame(asset) {
|
|
124
94
|
try {
|
|
125
95
|
var bbox = asset.getBoundingBox();
|
|
126
96
|
var cx = (bbox.min[0] + bbox.max[0]) / 2;
|
|
127
97
|
var cy = (bbox.min[1] + bbox.max[1]) / 2;
|
|
128
98
|
var cz = (bbox.min[2] + bbox.max[2]) / 2;
|
|
129
|
-
var
|
|
130
|
-
var sy = bbox.max[1] - bbox.min[1];
|
|
131
|
-
var sz = bbox.max[2] - bbox.min[2];
|
|
132
|
-
var maxDim = Math.max(sx, sy, sz);
|
|
99
|
+
var maxDim = Math.max(bbox.max[0] - bbox.min[0], bbox.max[1] - bbox.min[1], bbox.max[2] - bbox.min[2]);
|
|
133
100
|
if (maxDim > 0) {
|
|
134
101
|
this._orbitTarget = [cx, cy, cz];
|
|
135
|
-
this._orbitRadius = maxDim *
|
|
102
|
+
this._orbitRadius = maxDim * 1.8;
|
|
136
103
|
this._orbitHeight = cy;
|
|
137
104
|
}
|
|
138
|
-
} catch (e) {
|
|
139
|
-
|
|
105
|
+
} catch (e) {}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
addModel(url) {
|
|
109
|
+
var self = this;
|
|
110
|
+
return new Promise(function(resolve, reject) {
|
|
111
|
+
fetch(url)
|
|
112
|
+
.then(function(resp) { return resp.arrayBuffer(); })
|
|
113
|
+
.then(function(buffer) {
|
|
114
|
+
try {
|
|
115
|
+
var asset = self._loader.createAsset(new Uint8Array(buffer));
|
|
116
|
+
if (!asset) { reject(new Error('Failed to parse: ' + url)); return; }
|
|
117
|
+
asset.loadResources();
|
|
118
|
+
self._scene.addEntity(asset.getRoot());
|
|
119
|
+
self._scene.addEntities(asset.getRenderableEntities());
|
|
120
|
+
resolve(asset);
|
|
121
|
+
} catch (e) { reject(e); }
|
|
122
|
+
})
|
|
123
|
+
.catch(reject);
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
loadGLBBuffer(buffer) {
|
|
128
|
+
var asset = this._loader.createAsset(buffer);
|
|
129
|
+
if (!asset) return null;
|
|
130
|
+
asset.loadResources();
|
|
131
|
+
this._scene.addEntity(asset.getRoot());
|
|
132
|
+
this._scene.addEntities(asset.getRenderableEntities());
|
|
133
|
+
return asset;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
removeAsset(asset) {
|
|
137
|
+
if (!asset) return;
|
|
138
|
+
try {
|
|
139
|
+
asset.getRenderableEntities().forEach(function(e) { this._scene.remove(e); }.bind(this));
|
|
140
|
+
this._scene.remove(asset.getRoot());
|
|
141
|
+
} catch (e) {}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
get engine() { return this._engine; }
|
|
145
|
+
get scene() { return this._scene; }
|
|
146
|
+
get view() { return this._view; }
|
|
147
|
+
get camera() { return this._camera; }
|
|
148
|
+
|
|
149
|
+
// ── Basic setters ──
|
|
150
|
+
|
|
151
|
+
setAutoRotate(enabled) { this._autoRotate = enabled; this._wantsAutoRotate = enabled; return this; }
|
|
152
|
+
setCameraDistance(d) { this._orbitRadius = d; return this; }
|
|
153
|
+
|
|
154
|
+
setBackgroundColor(r, g, b, a) {
|
|
155
|
+
this._renderer.setClearOptions({ clearColor: [r, g, b, a !== undefined ? a : 1], clear: true });
|
|
156
|
+
return this;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ── Post-processing ──
|
|
160
|
+
|
|
161
|
+
setBloom(opts) {
|
|
162
|
+
try { this._view.setBloomOptions(Object.assign({ enabled: true, strength: 0.1, resolution: 360, levels: 6, blendMode: 0 }, opts)); } catch (e) {}
|
|
163
|
+
return this;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
setFog(opts) {
|
|
167
|
+
try { this._view.setFogOptions(Object.assign({ enabled: true, distance: 10, maximumOpacity: 0.8, color: [0.5, 0.55, 0.65] }, opts)); } catch (e) {}
|
|
168
|
+
return this;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
setVignette(opts) {
|
|
172
|
+
try { this._view.setVignetteOptions(Object.assign({ enabled: true, midPoint: 0.5, roundness: 0.5, feather: 0.5 }, opts)); } catch (e) {}
|
|
173
|
+
return this;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
setDOF(opts) {
|
|
177
|
+
try { this._view.setDepthOfFieldOptions(Object.assign({ enabled: true, cocScale: 1.0 }, opts)); } catch (e) {}
|
|
178
|
+
return this;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
setMSAA(opts) {
|
|
182
|
+
try { this._view.setMultiSampleAntiAliasingOptions(Object.assign({ enabled: true, sampleCount: 4 }, opts)); } catch (e) {}
|
|
183
|
+
return this;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
setTAA(opts) {
|
|
187
|
+
try { this._view.setTemporalAntiAliasingOptions(Object.assign({ enabled: true }, opts)); } catch (e) {}
|
|
188
|
+
return this;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
setSSR(opts) {
|
|
192
|
+
try { this._view.setScreenSpaceReflectionsOptions(Object.assign({ enabled: true }, opts)); } catch (e) {}
|
|
193
|
+
return this;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
setQuality(level) {
|
|
197
|
+
if (level === 'low') {
|
|
198
|
+
this.setBloom({ enabled: false }); this.setMSAA({ enabled: false });
|
|
199
|
+
} else if (level === 'medium') {
|
|
200
|
+
this.setBloom({ enabled: true, strength: 0.05 }); this.setMSAA({ enabled: true, sampleCount: 2 });
|
|
201
|
+
} else if (level === 'high') {
|
|
202
|
+
this.setBloom({ enabled: true, strength: 0.1 }); this.setMSAA({ enabled: true, sampleCount: 4 }); this.setVignette({ enabled: true });
|
|
203
|
+
} else if (level === 'ultra') {
|
|
204
|
+
this.setBloom({ enabled: true, strength: 0.15 }); this.setMSAA({ enabled: true, sampleCount: 4 });
|
|
205
|
+
this.setTAA({ enabled: true }); this.setSSR({ enabled: true }); this.setVignette({ enabled: true });
|
|
206
|
+
}
|
|
207
|
+
return this;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ── Camera helpers ──
|
|
211
|
+
|
|
212
|
+
setCameraPosition(x, y, z) {
|
|
213
|
+
this._orbitRadius = Math.sqrt(x * x + z * z);
|
|
214
|
+
this._orbitHeight = y;
|
|
215
|
+
this._angle = Math.atan2(x, z);
|
|
216
|
+
return this;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
setCameraTarget(x, y, z) { this._orbitTarget = [x, y, z]; return this; }
|
|
220
|
+
|
|
221
|
+
setCameraFOV(degrees) {
|
|
222
|
+
this._fov = degrees;
|
|
223
|
+
var c = this._canvas;
|
|
224
|
+
this._camera.setProjectionFov(degrees, c.width / c.height, 0.1, 1000, Filament.Camera$Fov.VERTICAL);
|
|
225
|
+
return this;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
setOrbitSpeed(speed) { this._orbitSpeed = speed; return this; }
|
|
229
|
+
|
|
230
|
+
setOrbitLimits(opts) {
|
|
231
|
+
if (opts.minDistance !== undefined) this._minRadius = opts.minDistance;
|
|
232
|
+
if (opts.maxDistance !== undefined) this._maxRadius = opts.maxDistance;
|
|
233
|
+
return this;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
animateCamera(opts) {
|
|
237
|
+
var self = this;
|
|
238
|
+
var duration = opts.duration || 1000;
|
|
239
|
+
var startTime = performance.now();
|
|
240
|
+
var sA = this._angle, sR = this._orbitRadius, sH = this._orbitHeight;
|
|
241
|
+
var sT = this._orbitTarget.slice();
|
|
242
|
+
var eT = opts.target || sT;
|
|
243
|
+
var eR = opts.distance !== undefined ? opts.distance : sR;
|
|
244
|
+
var eH = opts.height !== undefined ? opts.height : sH;
|
|
245
|
+
var eA = opts.angle !== undefined ? opts.angle : sA;
|
|
246
|
+
var wasAuto = this._autoRotate;
|
|
247
|
+
this._autoRotate = false;
|
|
248
|
+
this._cameraAnimating = true;
|
|
249
|
+
|
|
250
|
+
function ease(t) { return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; }
|
|
251
|
+
function lerp(a, b, t) { return a + (b - a) * t; }
|
|
252
|
+
|
|
253
|
+
function step(now) {
|
|
254
|
+
if (!self._running || !self._cameraAnimating) return;
|
|
255
|
+
var t = ease(Math.min((now - startTime) / duration, 1));
|
|
256
|
+
self._angle = lerp(sA, eA, t);
|
|
257
|
+
self._orbitRadius = lerp(sR, eR, t);
|
|
258
|
+
self._orbitHeight = lerp(sH, eH, t);
|
|
259
|
+
self._orbitTarget = [lerp(sT[0], eT[0], t), lerp(sT[1], eT[1], t), lerp(sT[2], eT[2], t)];
|
|
260
|
+
if (t < 1) { requestAnimationFrame(step); }
|
|
261
|
+
else { self._cameraAnimating = false; if (wasAuto) self._autoRotate = true; if (opts.onComplete) opts.onComplete(); }
|
|
140
262
|
}
|
|
263
|
+
requestAnimationFrame(step);
|
|
264
|
+
return this;
|
|
141
265
|
}
|
|
142
266
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
267
|
+
// ── Dynamic lights ──
|
|
268
|
+
|
|
269
|
+
addLight(type, opts) {
|
|
270
|
+
opts = opts || {};
|
|
271
|
+
var entity = Filament.EntityManager.get().create();
|
|
272
|
+
var typeMap = { sun: Filament.LightManager$Type.SUN, directional: Filament.LightManager$Type.DIRECTIONAL, point: Filament.LightManager$Type.POINT, spot: Filament.LightManager$Type.SPOT };
|
|
273
|
+
var builder = Filament.LightManager.Builder(typeMap[type] || Filament.LightManager$Type.POINT)
|
|
274
|
+
.color(opts.color || [1, 1, 1]).intensity(opts.intensity || 100000);
|
|
275
|
+
if (opts.direction) builder.direction(opts.direction);
|
|
276
|
+
if (opts.position) builder.position(opts.position);
|
|
277
|
+
if (opts.falloff) builder.falloff(opts.falloff);
|
|
278
|
+
if (opts.castShadows) builder.castShadows(true);
|
|
279
|
+
if (type === 'spot') builder.spotLightCone(opts.innerCone || 0.5, opts.outerCone || 0.7);
|
|
280
|
+
if (type === 'sun') { builder.sunAngularRadius(opts.angularRadius || 1.9); builder.sunHaloSize(opts.haloSize || 10.0); builder.sunHaloFalloff(opts.haloFalloff || 80.0); }
|
|
281
|
+
builder.build(this._engine, entity);
|
|
282
|
+
this._scene.addEntity(entity);
|
|
283
|
+
this._userLights.push(entity);
|
|
284
|
+
return entity;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
removeLight(entity) {
|
|
288
|
+
this._scene.remove(entity);
|
|
289
|
+
var i = this._userLights.indexOf(entity);
|
|
290
|
+
if (i >= 0) this._userLights.splice(i, 1);
|
|
146
291
|
return this;
|
|
147
292
|
}
|
|
148
293
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
this.
|
|
294
|
+
clearLights() {
|
|
295
|
+
this._userLights.forEach(function(e) { this._scene.remove(e); }.bind(this));
|
|
296
|
+
this._userLights = [];
|
|
152
297
|
return this;
|
|
153
298
|
}
|
|
154
299
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
300
|
+
// ── Skybox control ──
|
|
301
|
+
|
|
302
|
+
setSkyboxColor(r, g, b) {
|
|
303
|
+
try { this._scene.setSkybox(Filament.Skybox.Builder().color([r, g, b, 1]).build(this._engine)); } catch (e) {}
|
|
158
304
|
return this;
|
|
159
305
|
}
|
|
160
306
|
|
|
161
|
-
|
|
307
|
+
removeSkybox() { try { this._scene.setSkybox(null); } catch (e) {} return this; }
|
|
308
|
+
|
|
309
|
+
// ── Asset querying ──
|
|
310
|
+
|
|
311
|
+
getEntitiesByName(name) { if (!this._asset) return []; try { return this._asset.getEntitiesByName(name); } catch (e) { return []; } }
|
|
312
|
+
getEntitiesByPrefix(prefix) { if (!this._asset) return []; try { return this._asset.getEntitiesByPrefix(prefix); } catch (e) { return []; } }
|
|
313
|
+
|
|
314
|
+
// ── Lifecycle ──
|
|
315
|
+
|
|
162
316
|
dispose() {
|
|
163
317
|
this._running = false;
|
|
164
|
-
if (this._resizeObserver)
|
|
165
|
-
|
|
166
|
-
}
|
|
167
|
-
Filament.Engine.destroy(this._engine);
|
|
318
|
+
if (this._resizeObserver) this._resizeObserver.disconnect();
|
|
319
|
+
_activeCanvases.delete(this._canvas);
|
|
320
|
+
try { Filament.Engine.destroy(this._engine); } catch (e) {}
|
|
168
321
|
}
|
|
169
322
|
|
|
170
|
-
// --- Private ---
|
|
171
|
-
|
|
172
323
|
_setupControls() {
|
|
173
324
|
var canvas = this._canvas;
|
|
174
325
|
var self = this;
|
|
175
326
|
|
|
176
|
-
// Mouse orbit
|
|
177
327
|
canvas.addEventListener('mousedown', function(e) {
|
|
178
328
|
self._isDragging = true;
|
|
179
329
|
self._lastMouse = { x: e.clientX, y: e.clientY };
|
|
180
330
|
self._autoRotate = false;
|
|
331
|
+
self._velocityAngle = 0;
|
|
332
|
+
self._velocityHeight = 0;
|
|
333
|
+
if (self._autoRotateTimer) { clearTimeout(self._autoRotateTimer); self._autoRotateTimer = null; }
|
|
181
334
|
});
|
|
182
335
|
canvas.addEventListener('mousemove', function(e) {
|
|
183
336
|
if (!self._isDragging) return;
|
|
184
|
-
|
|
185
|
-
|
|
337
|
+
var dx = (e.clientX - self._lastMouse.x) * 0.005;
|
|
338
|
+
var dy = (e.clientY - self._lastMouse.y) * 0.01;
|
|
339
|
+
self._velocityAngle = -dx;
|
|
340
|
+
self._velocityHeight = dy;
|
|
341
|
+
self._angle -= dx;
|
|
342
|
+
self._orbitHeight += dy;
|
|
186
343
|
self._lastMouse = { x: e.clientX, y: e.clientY };
|
|
187
344
|
});
|
|
188
|
-
canvas.addEventListener('mouseup', function() {
|
|
189
|
-
|
|
345
|
+
canvas.addEventListener('mouseup', function() {
|
|
346
|
+
self._isDragging = false;
|
|
347
|
+
if (self._wantsAutoRotate) {
|
|
348
|
+
self._autoRotateTimer = setTimeout(function() { self._autoRotate = true; }, 3000);
|
|
349
|
+
}
|
|
350
|
+
});
|
|
351
|
+
canvas.addEventListener('mouseleave', function() {
|
|
352
|
+
self._isDragging = false;
|
|
353
|
+
if (self._wantsAutoRotate) {
|
|
354
|
+
self._autoRotateTimer = setTimeout(function() { self._autoRotate = true; }, 3000);
|
|
355
|
+
}
|
|
356
|
+
});
|
|
190
357
|
|
|
191
|
-
// Scroll zoom
|
|
192
358
|
canvas.addEventListener('wheel', function(e) {
|
|
193
359
|
e.preventDefault();
|
|
194
360
|
self._orbitRadius *= (1 + e.deltaY * 0.001);
|
|
195
|
-
self.
|
|
361
|
+
var minR = self._minRadius || 0.5;
|
|
362
|
+
var maxR = self._maxRadius || 50;
|
|
363
|
+
self._orbitRadius = Math.max(minR, Math.min(maxR, self._orbitRadius));
|
|
196
364
|
}, { passive: false });
|
|
197
365
|
|
|
198
|
-
// Touch orbit
|
|
199
366
|
canvas.addEventListener('touchstart', function(e) {
|
|
200
367
|
if (e.touches.length === 1) {
|
|
201
368
|
self._isDragging = true;
|
|
202
369
|
self._lastMouse = { x: e.touches[0].clientX, y: e.touches[0].clientY };
|
|
203
370
|
self._autoRotate = false;
|
|
371
|
+
self._velocityAngle = 0;
|
|
372
|
+
self._velocityHeight = 0;
|
|
373
|
+
if (self._autoRotateTimer) { clearTimeout(self._autoRotateTimer); self._autoRotateTimer = null; }
|
|
204
374
|
}
|
|
205
375
|
});
|
|
206
376
|
canvas.addEventListener('touchmove', function(e) {
|
|
207
377
|
if (!self._isDragging || e.touches.length !== 1) return;
|
|
208
378
|
e.preventDefault();
|
|
209
|
-
|
|
210
|
-
|
|
379
|
+
var dx = (e.touches[0].clientX - self._lastMouse.x) * 0.005;
|
|
380
|
+
var dy = (e.touches[0].clientY - self._lastMouse.y) * 0.01;
|
|
381
|
+
self._velocityAngle = -dx;
|
|
382
|
+
self._velocityHeight = dy;
|
|
383
|
+
self._angle -= dx;
|
|
384
|
+
self._orbitHeight += dy;
|
|
211
385
|
self._lastMouse = { x: e.touches[0].clientX, y: e.touches[0].clientY };
|
|
212
386
|
}, { passive: false });
|
|
213
|
-
canvas.addEventListener('touchend', function() {
|
|
387
|
+
canvas.addEventListener('touchend', function() {
|
|
388
|
+
self._isDragging = false;
|
|
389
|
+
if (self._wantsAutoRotate) {
|
|
390
|
+
self._autoRotateTimer = setTimeout(function() { self._autoRotate = true; }, 3000);
|
|
391
|
+
}
|
|
392
|
+
});
|
|
214
393
|
}
|
|
215
394
|
|
|
216
395
|
_setupResizeObserver() {
|
|
217
396
|
var self = this;
|
|
218
397
|
this._resizeObserver = new ResizeObserver(function() {
|
|
219
398
|
var canvas = self._canvas;
|
|
220
|
-
|
|
221
|
-
canvas.
|
|
399
|
+
var dpr = Math.min(devicePixelRatio, 2);
|
|
400
|
+
canvas.width = canvas.clientWidth * dpr;
|
|
401
|
+
canvas.height = canvas.clientHeight * dpr;
|
|
222
402
|
self._view.setViewport([0, 0, canvas.width, canvas.height]);
|
|
223
|
-
self._camera.setProjectionFov(
|
|
403
|
+
self._camera.setProjectionFov(
|
|
404
|
+
self._fov || 45, canvas.width / canvas.height, 0.1, 1000,
|
|
405
|
+
Filament.Camera$Fov.VERTICAL
|
|
406
|
+
);
|
|
224
407
|
});
|
|
225
408
|
this._resizeObserver.observe(this._canvas);
|
|
226
409
|
}
|
|
@@ -229,43 +412,52 @@
|
|
|
229
412
|
var self = this;
|
|
230
413
|
function render() {
|
|
231
414
|
if (!self._running) return;
|
|
232
|
-
if (self._autoRotate) self._angle += 0.
|
|
233
|
-
|
|
415
|
+
if (self._autoRotate) self._angle += (self._orbitSpeed || 0.00873);
|
|
416
|
+
if (!self._isDragging && !self._cameraAnimating) {
|
|
417
|
+
self._angle += self._velocityAngle;
|
|
418
|
+
self._orbitHeight += self._velocityHeight;
|
|
419
|
+
self._velocityAngle *= self._dampingFactor;
|
|
420
|
+
self._velocityHeight *= self._dampingFactor;
|
|
421
|
+
if (Math.abs(self._velocityAngle) < 0.00005) self._velocityAngle = 0;
|
|
422
|
+
if (Math.abs(self._velocityHeight) < 0.00005) self._velocityHeight = 0;
|
|
423
|
+
}
|
|
234
424
|
var t = self._orbitTarget;
|
|
235
425
|
var r = self._orbitRadius;
|
|
236
426
|
var h = self._orbitHeight;
|
|
237
427
|
self._camera.lookAt(
|
|
238
428
|
[t[0] + Math.sin(self._angle) * r, h, t[2] + Math.cos(self._angle) * r],
|
|
239
|
-
t,
|
|
240
|
-
[0, 1, 0]
|
|
429
|
+
t, [0, 1, 0]
|
|
241
430
|
);
|
|
242
|
-
|
|
243
|
-
if (self._renderer.beginFrame(self._swapChain)) {
|
|
244
|
-
self._renderer.render(self._swapChain, self._view);
|
|
245
|
-
self._renderer.endFrame();
|
|
246
|
-
}
|
|
247
431
|
self._engine.execute();
|
|
432
|
+
try {
|
|
433
|
+
if (self._renderer.beginFrame(self._swapChain)) {
|
|
434
|
+
self._renderer.renderView(self._view);
|
|
435
|
+
self._renderer.endFrame();
|
|
436
|
+
}
|
|
437
|
+
} catch (e) {
|
|
438
|
+
console.error('SceneView render error:', e.message);
|
|
439
|
+
self._running = false;
|
|
440
|
+
}
|
|
248
441
|
requestAnimationFrame(render);
|
|
249
442
|
}
|
|
250
443
|
render();
|
|
251
444
|
}
|
|
252
445
|
}
|
|
253
446
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
* Called inside a Filament.init() callback where WASM is guaranteed ready.
|
|
257
|
-
*/
|
|
447
|
+
var _activeCanvases = new Set();
|
|
448
|
+
|
|
258
449
|
function _createEngine(canvasOrId, options) {
|
|
259
450
|
options = options || {};
|
|
260
|
-
|
|
261
|
-
var canvas = typeof canvasOrId === 'string'
|
|
262
|
-
? document.getElementById(canvasOrId)
|
|
263
|
-
: canvasOrId;
|
|
264
|
-
|
|
451
|
+
var canvas = typeof canvasOrId === 'string' ? document.getElementById(canvasOrId) : canvasOrId;
|
|
265
452
|
if (!canvas) throw new Error('Canvas not found: ' + canvasOrId);
|
|
453
|
+
if (_activeCanvases.has(canvas)) { console.warn('SceneView: Canvas already initialized, skipping'); return null; }
|
|
454
|
+
_activeCanvases.add(canvas);
|
|
266
455
|
|
|
267
|
-
|
|
268
|
-
canvas.
|
|
456
|
+
var dpr = Math.min(devicePixelRatio, 2);
|
|
457
|
+
var cssW = canvas.clientWidth || canvas.offsetWidth || 500;
|
|
458
|
+
var cssH = canvas.clientHeight || canvas.offsetHeight || 500;
|
|
459
|
+
canvas.width = cssW * dpr;
|
|
460
|
+
canvas.height = cssH * dpr;
|
|
269
461
|
|
|
270
462
|
var engine = Filament.Engine.create(canvas);
|
|
271
463
|
var scene = engine.createScene();
|
|
@@ -282,106 +474,116 @@
|
|
|
282
474
|
var bg = options.backgroundColor || [0.05, 0.06, 0.1, 1.0];
|
|
283
475
|
renderer.setClearOptions({ clearColor: bg, clear: true });
|
|
284
476
|
|
|
285
|
-
var
|
|
286
|
-
camera.setProjectionFov(
|
|
477
|
+
var fov = options.fov || 45;
|
|
478
|
+
camera.setProjectionFov(fov, canvas.width / canvas.height, 0.1, 1000, Filament.Camera$Fov.VERTICAL);
|
|
287
479
|
camera.lookAt([0, 1, 5], [0, 0, 0], [0, 1, 0]);
|
|
288
480
|
|
|
289
|
-
|
|
481
|
+
try { view.setAmbientOcclusionOptions({ enabled: true, radius: 0.3, bias: 0.0005, intensity: 1.0, quality: 1 }); } catch (e) {}
|
|
482
|
+
|
|
290
483
|
var sun = Filament.EntityManager.get().create();
|
|
291
484
|
Filament.LightManager.Builder(Filament.LightManager$Type.SUN)
|
|
292
|
-
.color([0.98, 0.92, 0.89])
|
|
293
|
-
.
|
|
294
|
-
.direction([0.6, -1.0, -0.8])
|
|
295
|
-
.sunAngularRadius(1.9)
|
|
296
|
-
.sunHaloSize(10.0)
|
|
297
|
-
.sunHaloFalloff(80.0)
|
|
485
|
+
.color([0.98, 0.92, 0.89]).intensity(options.lightIntensity || 110000)
|
|
486
|
+
.direction([0.6, -1.0, -0.8]).sunAngularRadius(1.9).sunHaloSize(10.0).sunHaloFalloff(80.0)
|
|
298
487
|
.build(engine, sun);
|
|
299
488
|
scene.addEntity(sun);
|
|
300
489
|
|
|
301
|
-
// Fill light
|
|
302
490
|
var fill = Filament.EntityManager.get().create();
|
|
303
491
|
Filament.LightManager.Builder(Filament.LightManager$Type.DIRECTIONAL)
|
|
304
|
-
.color([0.
|
|
305
|
-
.intensity(30000)
|
|
306
|
-
.direction([-0.5, 0.5, 1.0])
|
|
492
|
+
.color([0.7, 0.75, 0.9]).intensity(60000).direction([-0.5, 0.5, 1.0])
|
|
307
493
|
.build(engine, fill);
|
|
308
494
|
scene.addEntity(fill);
|
|
309
495
|
|
|
310
|
-
|
|
311
|
-
|
|
496
|
+
var back = Filament.EntityManager.get().create();
|
|
497
|
+
Filament.LightManager.Builder(Filament.LightManager$Type.DIRECTIONAL)
|
|
498
|
+
.color([0.5, 0.6, 0.9]).intensity(50000).direction([0, 0.3, 1.0])
|
|
499
|
+
.build(engine, back);
|
|
500
|
+
scene.addEntity(back);
|
|
501
|
+
|
|
502
|
+
var iblUrl = options.iblUrl || 'environments/neutral_ibl.ktx';
|
|
503
|
+
fetch(iblUrl)
|
|
504
|
+
.then(function(r) {
|
|
505
|
+
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
506
|
+
return r.arrayBuffer().then(function(ab) { return new Uint8Array(ab); });
|
|
507
|
+
})
|
|
508
|
+
.then(function(buffer) {
|
|
509
|
+
try {
|
|
510
|
+
var ibl = engine.createIblFromKtx1(buffer);
|
|
511
|
+
ibl.setIntensity(options.iblIntensity || 40000);
|
|
512
|
+
scene.setIndirectLight(ibl);
|
|
513
|
+
if (options.skybox !== false) {
|
|
514
|
+
try {
|
|
515
|
+
var reflections = ibl.getReflectionsTexture();
|
|
516
|
+
if (reflections) {
|
|
517
|
+
scene.setSkybox(Filament.Skybox.Builder().environment(reflections).build(engine));
|
|
518
|
+
console.log('SceneView: Skybox created from IBL cubemap');
|
|
519
|
+
}
|
|
520
|
+
} catch (skyErr) { console.log('SceneView: Skybox not available (IBL-only mode)'); }
|
|
521
|
+
}
|
|
522
|
+
console.log('SceneView: KTX IBL loaded (' + Math.round(buffer.length / 1024) + 'KB)');
|
|
523
|
+
} catch (e) {
|
|
524
|
+
console.warn('SceneView: createIblFromKtx1 failed, using SH fallback', e);
|
|
525
|
+
_applySyntheticIBL(engine, scene);
|
|
526
|
+
}
|
|
527
|
+
})
|
|
528
|
+
.catch(function() { _applySyntheticIBL(engine, scene); });
|
|
312
529
|
|
|
530
|
+
var loader = engine.createAssetLoader();
|
|
313
531
|
var instance = new SceneViewInstance(canvas, engine, scene, renderer, view, swapChain, camera, cameraEntity, loader);
|
|
532
|
+
instance._fov = fov;
|
|
314
533
|
|
|
315
|
-
if (options.autoRotate === false)
|
|
316
|
-
|
|
317
|
-
}
|
|
534
|
+
if (options.autoRotate === false) instance.setAutoRotate(false);
|
|
535
|
+
if (options.quality) instance.setQuality(options.quality);
|
|
536
|
+
if (options.bloom) instance.setBloom(typeof options.bloom === 'object' ? options.bloom : {});
|
|
537
|
+
if (options.fog) instance.setFog(typeof options.fog === 'object' ? options.fog : {});
|
|
538
|
+
if (options.vignette) instance.setVignette(typeof options.vignette === 'object' ? options.vignette : {});
|
|
539
|
+
if (options.dof) instance.setDOF(typeof options.dof === 'object' ? options.dof : {});
|
|
540
|
+
if (options.msaa) instance.setMSAA(typeof options.msaa === 'object' ? options.msaa : {});
|
|
318
541
|
|
|
319
542
|
return instance;
|
|
320
543
|
}
|
|
321
544
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
545
|
+
function _applySyntheticIBL(engine, scene) {
|
|
546
|
+
try {
|
|
547
|
+
var ibl = Filament.IndirectLight.Builder()
|
|
548
|
+
.irradiance(3, [
|
|
549
|
+
0.65, 0.65, 0.70, 0.10, 0.10, 0.12, 0.15, 0.15, 0.18,
|
|
550
|
+
-0.02, -0.02, -0.01, 0.04, 0.04, 0.05, 0.08, 0.08, 0.10,
|
|
551
|
+
0.01, 0.01, 0.01, -0.02, -0.02, -0.02, 0.03, 0.03, 0.03
|
|
552
|
+
])
|
|
553
|
+
.intensity(35000).build(engine);
|
|
554
|
+
scene.setIndirectLight(ibl);
|
|
555
|
+
console.log('SceneView: Using synthetic SH IBL');
|
|
556
|
+
} catch (e) {}
|
|
557
|
+
}
|
|
558
|
+
|
|
330
559
|
function create(canvasOrId, options) {
|
|
331
560
|
return _ensureFilament().then(function() {
|
|
332
561
|
return new Promise(function(resolve, reject) {
|
|
333
|
-
// If WASM is already initialized (Engine exists), skip Filament.init
|
|
334
562
|
if (typeof Filament.Engine !== 'undefined') {
|
|
335
563
|
try {
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
}
|
|
564
|
+
var instance = _createEngine(canvasOrId, options);
|
|
565
|
+
if (instance) resolve(instance); else reject(new Error('SceneView: Canvas already initialized'));
|
|
566
|
+
} catch (e) { reject(e); }
|
|
340
567
|
return;
|
|
341
568
|
}
|
|
342
|
-
// First time: initialize WASM
|
|
343
569
|
Filament.init([], function() {
|
|
344
570
|
try {
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
}
|
|
571
|
+
var instance = _createEngine(canvasOrId, options);
|
|
572
|
+
if (instance) resolve(instance); else reject(new Error('SceneView: Canvas already initialized'));
|
|
573
|
+
} catch (e) { reject(e); }
|
|
349
574
|
});
|
|
350
575
|
});
|
|
351
576
|
});
|
|
352
577
|
}
|
|
353
578
|
|
|
354
|
-
/**
|
|
355
|
-
* One-liner: create viewer and load a model.
|
|
356
|
-
* Filament.js is loaded automatically from CDN if not already present.
|
|
357
|
-
*
|
|
358
|
-
* @param {string|HTMLCanvasElement} canvasOrId
|
|
359
|
-
* @param {string} modelUrl - URL to .glb/.gltf model
|
|
360
|
-
* @param {Object} [options]
|
|
361
|
-
* @returns {Promise<SceneViewInstance>}
|
|
362
|
-
*/
|
|
363
579
|
function modelViewer(canvasOrId, modelUrl, options) {
|
|
364
|
-
return
|
|
365
|
-
return
|
|
366
|
-
// Always use Filament.init with the model URL in the assets array.
|
|
367
|
-
// This works whether WASM is already loaded or not, because Filament
|
|
368
|
-
// needs to fetch the model asset and will call back when done.
|
|
369
|
-
Filament.init([modelUrl], function() {
|
|
370
|
-
try {
|
|
371
|
-
var instance = _createEngine(canvasOrId, options);
|
|
372
|
-
instance._showModel(modelUrl);
|
|
373
|
-
resolve(instance);
|
|
374
|
-
} catch (e) {
|
|
375
|
-
reject(e);
|
|
376
|
-
}
|
|
377
|
-
});
|
|
378
|
-
});
|
|
580
|
+
return create(canvasOrId, options).then(function(instance) {
|
|
581
|
+
return instance.loadModel(modelUrl);
|
|
379
582
|
});
|
|
380
583
|
}
|
|
381
584
|
|
|
382
|
-
// Public API
|
|
383
585
|
global.SceneView = {
|
|
384
|
-
version: '1.
|
|
586
|
+
version: '1.5.0',
|
|
385
587
|
create: create,
|
|
386
588
|
modelViewer: modelViewer
|
|
387
589
|
};
|