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/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
- * No prerequisites — sceneview.js loads Filament.js CDN automatically.
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.1.0
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
- // Already loaded
29
- if (typeof Filament !== 'undefined') {
30
- resolve();
31
- return;
32
- }
33
- // Check if script tag already exists but hasn't finished loading
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
- /** Load a glTF/GLB model from URL */
58
+ // ── Model loading ──
59
+
78
60
  loadModel(url) {
79
61
  var self = this;
80
62
  return new Promise(function(resolve, reject) {
81
- // If already fetched, show immediately
82
- if (Filament.assets && Filament.assets[url]) {
83
- try {
84
- self._showModel(url);
85
- resolve(self);
86
- } catch (e) {
87
- reject(e);
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
- this._asset.getRenderableEntities().forEach(function(e) { this._scene.remove(e); }.bind(this));
108
- this._scene.remove(this._asset.getRoot());
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
- // Auto-frame: try getBoundingBox, fall back to defaults
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 sx = bbox.max[0] - bbox.min[0];
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 * 2.5;
102
+ this._orbitRadius = maxDim * 1.8;
136
103
  this._orbitHeight = cy;
137
104
  }
138
- } catch (e) {
139
- // getBoundingBox not available on all assets, use defaults
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
- /** Enable/disable auto-rotation */
144
- setAutoRotate(enabled) {
145
- this._autoRotate = enabled;
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
- /** Set camera orbit distance */
150
- setCameraDistance(distance) {
151
- this._orbitRadius = distance;
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
- /** Set background color [r, g, b, a] (0-1 range) */
156
- setBackgroundColor(r, g, b, a) {
157
- this._renderer.setClearOptions({ clearColor: [r, g, b, a !== undefined ? a : 1], clear: true });
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
- /** Dispose all resources */
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
- this._resizeObserver.disconnect();
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
- self._angle -= (e.clientX - self._lastMouse.x) * 0.005;
185
- self._orbitHeight += (e.clientY - self._lastMouse.y) * 0.01;
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() { self._isDragging = false; });
189
- canvas.addEventListener('mouseleave', function() { self._isDragging = false; });
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._orbitRadius = Math.max(0.5, Math.min(50, self._orbitRadius));
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
- self._angle -= (e.touches[0].clientX - self._lastMouse.x) * 0.005;
210
- self._orbitHeight += (e.touches[0].clientY - self._lastMouse.y) * 0.01;
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() { self._isDragging = false; });
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
- canvas.width = canvas.clientWidth * devicePixelRatio;
221
- canvas.height = canvas.clientHeight * devicePixelRatio;
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(45, canvas.width / canvas.height, 0.1, 1000, Filament.Camera$Fov.VERTICAL);
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.006;
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
- * Internal: set up Filament engine, scene, lights on a canvas.
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
- canvas.width = canvas.clientWidth * devicePixelRatio;
268
- canvas.height = canvas.clientHeight * devicePixelRatio;
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 aspect = canvas.width / canvas.height;
286
- camera.setProjectionFov(options.fov || 45, aspect, 0.1, 1000, Filament.Camera$Fov.VERTICAL);
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
- // Default sunlight
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
- .intensity(options.lightIntensity || 110000)
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.6, 0.65, 0.8])
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
- // Asset loader (reused across model loads)
311
- var loader = engine.createAssetLoader();
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
- instance.setAutoRotate(false);
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
- * Create an empty SceneView on a canvas.
324
- * Filament.js is loaded automatically from CDN if not already present.
325
- *
326
- * @param {string|HTMLCanvasElement} canvasOrId - Canvas element or its ID
327
- * @param {Object} [options] - Configuration options
328
- * @returns {Promise<SceneViewInstance>}
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
- resolve(_createEngine(canvasOrId, options));
337
- } catch (e) {
338
- reject(e);
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
- resolve(_createEngine(canvasOrId, options));
346
- } catch (e) {
347
- reject(e);
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 _ensureFilament().then(function() {
365
- return new Promise(function(resolve, reject) {
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.1.0',
586
+ version: '1.5.0',
385
587
  create: create,
386
588
  modelViewer: modelViewer
387
589
  };