trid3d 0.1.0 → 0.2.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.
@@ -1,7 +1,8 @@
1
1
  import { DEFAULT_FOV, DEFAULT_NEAR } from "../math/transform.js";
2
- import { clear, edgeSegments } from "./draw.js";
2
+ import { clear, edgeSegments, wireEdges } from "./draw.js";
3
3
  import { parseColor } from "./color.js";
4
- import { DEFAULT_LIGHT, DEFAULT_AMBIENT } from "./canvasRenderer.js";
4
+ import { DEFAULT_LIGHT, DEFAULT_AMBIENT, materialsOf } from "./canvasRenderer.js";
5
+ import { uvsFor, uvTransform, mappingKey } from "./uvMapping.js";
5
6
  import { newell, cornerNormals, creaseOf } from "./normals.js";
6
7
  import { SURFACE_GLSL, buildEffects, effectsKey } from "./effects.js";
7
8
  import { textureReady, textureSize, textureIsLive } from "../loaders/texture.js";
@@ -38,11 +39,12 @@ varying vec3 vNormal;
38
39
  varying vec3 vWorld;
39
40
  varying vec3 vLocal;
40
41
  varying vec2 vUV;
42
+ uniform mat3 uUVTransform; // textureRepeat, textureOffset, textureRotation
41
43
  ${COMMON}
42
44
  void main() {
43
45
  vNormal = rotXYZ(aNormal / uScale); // inverse scale keeps normals right on stretched objects
44
46
  vLocal = aPos;
45
- vUV = aUV;
47
+ vUV = (uUVTransform * vec3(aUV, 1.0)).xy;
46
48
  vWorld = toWorld(aPos);
47
49
  gl_Position = toClip(toCamera(vWorld));
48
50
  }
@@ -106,7 +108,7 @@ ${fx.calls}
106
108
  return { source, uniforms: fx.uniforms };
107
109
  }
108
110
 
109
- const FACE_UNIFORMS = ["uLight", "uAmbient", "uShade", "uTime", "uBackground", "uCamWorld", "uTexture", "uUseTexture"];
111
+ const FACE_UNIFORMS = ["uUVTransform", "uLight", "uAmbient", "uShade", "uTime", "uBackground", "uCamWorld", "uTexture", "uUseTexture"];
110
112
 
111
113
  // Each line segment is a thin rectangle facing the screen, `uWidth` pixels wide
112
114
  const LINE_VS = `
@@ -268,16 +270,26 @@ export class WebGLRenderer {
268
270
  const geo = this._geometry(obj.mesh);
269
271
  const lineColor = this._color(ctx, obj.color ?? scene.color);
270
272
  const lineWidth = obj.lineWidth ?? scene.lineWidth;
271
- const mat = obj.material;
272
-
273
- if (mat) {
274
- const fill = this._color(ctx, mat.fill ?? (mat.texture ? "#ffffff" : scene.color));
275
- (fill[3] < 1 ? clear2 : opaque).push({ kind: "face", obj, geo, color: fill, mat });
276
- if (mat.wireframe) {
277
- (lineColor[3] < 1 ? clear2 : opaque).push({ kind: "line", obj, buf: geo.outlines, color: lineColor, width: lineWidth });
273
+ const mats = materialsOf(obj);
274
+
275
+ if (mats) {
276
+ // One draw per group of faces sharing a material (mesh.faceMaterials)
277
+ const groups = geo.groups.map((g) => {
278
+ const k = g.index >= 0 && g.index < mats.length && mats[g.index] ? g.index : 0;
279
+ return { ...g, mat: mats[k] };
280
+ });
281
+ for (const g of groups) {
282
+ const fill = this._color(ctx, g.mat.fill ?? (g.mat.texture ? "#ffffff" : scene.color));
283
+ (fill[3] < 1 ? clear2 : opaque).push({ kind: "face", obj, geo, color: fill, mat: g.mat, first: g.first, count: g.count });
278
284
  }
285
+ const lines = (buf) => (lineColor[3] < 1 ? clear2 : opaque).push({ kind: "line", obj, buf, color: lineColor, width: lineWidth });
286
+ if (groups.every((g) => g.mat.wireframe)) lines(this._lineBuffer(geo, "outlines", () => outlinePairs(geo.faceList ?? [])));
287
+ else for (const g of groups) if (g.mat.wireframe) lines(this._outlineBuffer(geo, obj.mesh, g));
279
288
  } else {
280
- (lineColor[3] < 1 ? clear2 : opaque).push({ kind: "line", obj, buf: geo.edges, color: lineColor, width: lineWidth });
289
+ (lineColor[3] < 1 ? clear2 : opaque).push({
290
+ kind: "line", obj, color: lineColor, width: lineWidth,
291
+ buf: this._lineBuffer(geo, "edges", () => edgeSegments(wireEdges(obj.mesh))),
292
+ });
281
293
  }
282
294
  }
283
295
 
@@ -310,8 +322,11 @@ export class WebGLRenderer {
310
322
  const isFace = d.kind === "face";
311
323
  const effects = isFace ? d.mat.effects ?? [] : null;
312
324
  const prog = isFace ? this._faceProgram(effects) : this._line;
313
- const buf = isFace ? this._faceBuffer(d.geo, d.obj.mesh, d.mat.shade === false ? null : creaseOf(d.mat.smooth)) : d.buf;
314
- if (!buf.count) return;
325
+ const buf = isFace
326
+ ? this._faceBuffer(d.geo, d.obj.mesh, d.mat.shade === false ? null : creaseOf(d.mat.smooth), d.mat.mapping)
327
+ : d.buf;
328
+ const first = d.first ?? 0, count = d.count ?? buf.count;
329
+ if (!count) return;
315
330
 
316
331
  gl.useProgram(prog.program);
317
332
  const u = prog.uniforms;
@@ -331,6 +346,8 @@ export class WebGLRenderer {
331
346
  const a = prog.attributes;
332
347
 
333
348
  if (isFace) {
349
+ const m = uvTransform(d.mat) ?? [1, 0, 0, 1, 0, 0];
350
+ gl.uniformMatrix3fv(u.uUVTransform, false, [m[0], m[1], 0, m[2], m[3], 0, m[4], m[5], 1]);
334
351
  gl.uniform3fv(u.uLight, light);
335
352
  gl.uniform1f(u.uAmbient, ambient);
336
353
  gl.uniform1f(u.uShade, d.mat.shade === false ? 0 : 1);
@@ -346,7 +363,9 @@ export class WebGLRenderer {
346
363
  gl.vertexAttribPointer(a.aPos, 3, gl.FLOAT, false, 32, 0);
347
364
  if (a.aNormal >= 0) gl.vertexAttribPointer(a.aNormal, 3, gl.FLOAT, false, 32, 12);
348
365
  if (a.aUV >= 0) gl.vertexAttribPointer(a.aUV, 2, gl.FLOAT, false, 32, 24);
349
- const tex = d.mat.texture ? this._texture(d.mat.texture, !!d.mat.pixelated, d.obj.mesh) : null;
366
+ const tex = d.mat.texture
367
+ ? this._texture(d.mat.texture, !!d.mat.pixelated, d.obj.mesh, !!uvsFor(d.obj.mesh, d.mat.mapping).faceUVs)
368
+ : null;
350
369
  gl.activeTexture(gl.TEXTURE0);
351
370
  gl.bindTexture(gl.TEXTURE_2D, tex);
352
371
  gl.uniform1i(u.uTexture, 0);
@@ -363,7 +382,7 @@ export class WebGLRenderer {
363
382
  }
364
383
  const locs = Object.values(a).filter((loc) => loc >= 0);
365
384
  for (const loc of locs) gl.enableVertexAttribArray(loc);
366
- gl.drawArrays(gl.TRIANGLES, 0, buf.count);
385
+ gl.drawArrays(gl.TRIANGLES, first, count);
367
386
  for (const loc of locs) gl.disableVertexAttribArray(loc);
368
387
  }
369
388
 
@@ -398,13 +417,13 @@ export class WebGLRenderer {
398
417
  * (canvases and videos: every frame). Null while it isn't ready,
399
418
  * or if the mesh has no texture coordinates.
400
419
  */
401
- _texture(src, pixelated, mesh) {
420
+ _texture(src, pixelated, mesh, hasUVs) {
402
421
  const gl = this.gl;
403
422
  if (!textureReady(src)) return null;
404
- if (!mesh.faceUVs) {
423
+ if (!hasUVs) {
405
424
  if (!this._warned.has(mesh)) {
406
425
  this._warned.add(mesh);
407
- console.warn("Trid: this mesh has no texture coordinates (uvs / faceUVs), so its texture isn't drawn");
426
+ console.warn("Trid: this mesh has no texture coordinates (uvs / faceUVs), so its texture isn't drawn. Set material.mapping to make some.");
408
427
  }
409
428
  return null;
410
429
  }
@@ -413,7 +432,7 @@ export class WebGLRenderer {
413
432
  const live = textureIsLive(src);
414
433
  if (!t || live || t.failed) {
415
434
  if (t?.failed) return null;
416
- const { w, h } = textureSize(src);
435
+ let { w, h } = textureSize(src);
417
436
  if (!t) {
418
437
  t = { tex: gl.createTexture(), frame: -1 };
419
438
  this._textures.set(src, t);
@@ -422,9 +441,23 @@ export class WebGLRenderer {
422
441
  if (t.frame !== this._frame) {
423
442
  t.frame = this._frame;
424
443
  gl.bindTexture(gl.TEXTURE_2D, t.tex);
444
+ // Bigger than the graphics card allows: upload a smaller copy
445
+ let pixels = src;
446
+ const max = gl.getParameter(gl.MAX_TEXTURE_SIZE);
447
+ if (w > max || h > max) {
448
+ const k = max / Math.max(w, h);
449
+ if (!t.small || live) t.small = shrink(src, Math.floor(w * k), Math.floor(h * k), t.small);
450
+ if (!t.warned) {
451
+ t.warned = true;
452
+ console.warn(`Trid: a ${w} × ${h} texture is bigger than this graphics card allows (${max}), so it's drawn at ${t.small.width} × ${t.small.height}`);
453
+ }
454
+ pixels = t.small;
455
+ w = t.small.width;
456
+ h = t.small.height;
457
+ }
425
458
  gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true); // images go top-down, v goes up
426
459
  try {
427
- gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, src);
460
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
428
461
  } catch (err) {
429
462
  // Usually an image from another site without permission (CORS)
430
463
  console.warn("Trid: can't use this image as a texture: " + err.message);
@@ -465,59 +498,87 @@ export class WebGLRenderer {
465
498
  _geometry(mesh) {
466
499
  let geo = this._meshes.get(mesh);
467
500
  const stale = !geo || mesh.dirty || geo.vertices !== mesh.vertices || geo.faceList !== mesh.faces
468
- || geo.edgeList !== mesh.edges || geo.counts !== countsOf(mesh);
501
+ || geo.edgeList !== mesh.edges || geo.faceMats !== mesh.faceMaterials || geo.counts !== countsOf(mesh);
469
502
  if (!stale) return geo;
470
503
 
471
504
  if (geo) {
472
- for (const b of [geo.outlines, geo.edges, ...geo.faceBuffers.values()]) this._free(b);
505
+ for (const b of [...geo.lines.values(), ...geo.faceBuffers.values(), ...geo.groupOutlines.values()]) this._free(b);
473
506
  }
474
- const v = mesh.vertices;
475
507
  const faces = mesh.faces ?? [];
476
508
 
477
- // Outlines of the faces, each edge once
478
- const outline = new Set();
479
- const outlinePairs = [];
480
- for (const f of faces) {
481
- for (let k = 0; k < f.length; k++) {
482
- const a = f[k], b = f[(k + 1) % f.length];
483
- const key = a < b ? a + "," + b : b + "," + a;
484
- if (!outline.has(key)) {
485
- outline.add(key);
486
- outlinePairs.push([a, b]);
487
- }
488
- }
509
+ // Faces grouped by material index, in the order the face buffers store them
510
+ const byIndex = new Map();
511
+ faces.forEach((f, fi) => {
512
+ if (f.length < 3) return;
513
+ const k = mesh.faceMaterials?.[fi] ?? 0;
514
+ if (!byIndex.has(k)) byIndex.set(k, []);
515
+ byIndex.get(k).push(fi);
516
+ });
517
+ const groups = [];
518
+ let first = 0;
519
+ for (const k of [...byIndex.keys()].sort((a, b) => a - b)) {
520
+ const list = byIndex.get(k);
521
+ const count = list.reduce((n, fi) => n + (faces[fi].length - 2) * 3, 0);
522
+ groups.push({ index: k, faces: list, first, count });
523
+ first += count;
489
524
  }
490
525
 
491
526
  geo = {
492
527
  vertices: mesh.vertices,
493
528
  faceList: mesh.faces,
494
529
  edgeList: mesh.edges,
530
+ faceMats: mesh.faceMaterials,
495
531
  counts: countsOf(mesh),
496
- faceBuffers: new Map(), // one per shading mode, built when first needed
497
- outlines: this._upload(lineData(v, outlinePairs), 8),
498
- edges: this._upload(lineData(v, edgeSegments(mesh.edges ?? [])), 8),
532
+ groups,
533
+ groupOutlines: new Map(), // outlines of one group, when only some materials have `wireframe`
534
+ faceBuffers: new Map(), // one per shading mode and mapping, built when first needed
535
+ lines: new Map(), // "outlines" and "edges", built when first drawn (they're big on big models)
499
536
  };
500
537
  this._meshes.set(mesh, geo);
501
538
  return geo;
502
539
  }
503
540
 
541
+ /** A line buffer of the mesh, built the first time it's drawn. */
542
+ _lineBuffer(geo, name, pairs) {
543
+ let buf = geo.lines.get(name);
544
+ if (!buf) {
545
+ buf = this._upload(lineData(geo.vertices, pairs()), 8);
546
+ geo.lines.set(name, buf);
547
+ }
548
+ return buf;
549
+ }
550
+
551
+ /** Outlines of one group of faces (see `_geometry`), built when first needed. */
552
+ _outlineBuffer(geo, mesh, group) {
553
+ let buf = geo.groupOutlines.get(group.index);
554
+ if (!buf) {
555
+ const faces = mesh.faces ?? [];
556
+ buf = this._upload(lineData(mesh.vertices, outlinePairs(group.faces.map((fi) => faces[fi]))), 8);
557
+ geo.groupOutlines.set(group.index, buf);
558
+ }
559
+ return buf;
560
+ }
561
+
504
562
  /**
505
- * Triangles of a mesh's faces, each corner with its own position and
506
- * normal. Flat shading uses the face's normal at every corner; smooth
507
- * shading uses the corner normals.
563
+ * Triangles of a mesh's faces, each corner with its own position,
564
+ * normal and texture coordinate, grouped by material (`geo.groups`).
565
+ * Flat shading uses the face's normal at every corner; smooth shading
566
+ * uses the corner normals. `mapping` picks the texture coordinates.
508
567
  */
509
- _faceBuffer(geo, mesh, crease) {
510
- const key = crease ?? "flat";
568
+ _faceBuffer(geo, mesh, crease, mapping) {
569
+ const key = (crease ?? "flat") + "|" + mappingKey(mapping);
511
570
  let buf = geo.faceBuffers.get(key);
512
571
  if (buf) return buf;
513
572
 
514
573
  const v = mesh.vertices;
515
574
  const faces = mesh.faces ?? [];
516
575
  const corners = crease ? cornerNormals(mesh, crease) : null;
517
- const uvs = mesh.uvs, faceUVs = mesh.faceUVs;
518
- const tri = [];
519
- faces.forEach((f, fi) => {
520
- if (f.length < 3) return;
576
+ const { uvs, faceUVs } = uvsFor(mesh, mapping);
577
+ const total = geo.groups.reduce((n, g) => n + g.count, 0);
578
+ const tri = new Float32Array(total * 8);
579
+ let o = 0;
580
+ for (const fi of geo.groups.flatMap((g) => g.faces)) {
581
+ const f = faces[fi];
521
582
  let ns;
522
583
  if (corners) {
523
584
  ns = corners[fi];
@@ -532,10 +593,12 @@ export class WebGLRenderer {
532
593
  for (const c of [0, k, k + 1]) {
533
594
  const p = v[f[c]], n = ns[c];
534
595
  const t = ts ? uvs[ts[c]] : null;
535
- tri.push(p.x, p.y, p.z, n.x, n.y, n.z, t ? t.u : 0, t ? t.v : 0);
596
+ tri[o++] = p.x; tri[o++] = p.y; tri[o++] = p.z;
597
+ tri[o++] = n.x; tri[o++] = n.y; tri[o++] = n.z;
598
+ tri[o++] = t ? t.u : 0; tri[o++] = t ? t.v : 0;
536
599
  }
537
600
  }
538
- });
601
+ }
539
602
 
540
603
  buf = this._upload(tri, 8);
541
604
  geo.faceBuffers.set(key, buf);
@@ -546,7 +609,7 @@ export class WebGLRenderer {
546
609
  const gl = this.gl;
547
610
  const buffer = gl.createBuffer();
548
611
  gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
549
- gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(data), gl.STATIC_DRAW);
612
+ gl.bufferData(gl.ARRAY_BUFFER, data instanceof Float32Array ? data : new Float32Array(data), gl.STATIC_DRAW);
550
613
  this._buffers.add(buffer);
551
614
  return { buffer, count: data.length / floatsPerVertex };
552
615
  }
@@ -573,13 +636,44 @@ function countsOf(mesh) {
573
636
  return `${mesh.vertices.length}/${mesh.edges?.length ?? 0}/${mesh.faces?.length ?? 0}`;
574
637
  }
575
638
 
639
+ /** A smaller copy of an image, drawn on a canvas (reused when given one). */
640
+ function shrink(src, w, h, canvas = document.createElement("canvas")) {
641
+ canvas.width = w;
642
+ canvas.height = h;
643
+ canvas.getContext("2d").drawImage(src, 0, 0, w, h);
644
+ return canvas;
645
+ }
646
+
647
+ /** Each edge of the faces once, as [a, b] pairs. */
648
+ function outlinePairs(faces) {
649
+ const seen = new Set();
650
+ const pairs = [];
651
+ for (const f of faces) {
652
+ for (let k = 0; k < f.length; k++) {
653
+ const a = f[k], b = f[(k + 1) % f.length];
654
+ const key = a < b ? a * 4294967296 + b : b * 4294967296 + a; // one number per edge
655
+ if (!seen.has(key)) {
656
+ seen.add(key);
657
+ pairs.push([a, b]);
658
+ }
659
+ }
660
+ }
661
+ return pairs;
662
+ }
663
+
576
664
  /** Six corners (two triangles) per segment: A, B, then which corner. */
577
- function lineData(v, pairs) {
578
- const out = [];
665
+ function lineData(v, segments) {
666
+ const pairs = Array.isArray(segments) ? segments : [...segments]; // edgeSegments is a generator
667
+ const out = new Float32Array(pairs.length * 48);
579
668
  const corners = [[0, -1], [1, -1], [1, 1], [0, -1], [1, 1], [0, 1]];
669
+ let o = 0;
580
670
  for (const [i, j] of pairs) {
581
671
  const a = v[i], b = v[j];
582
- for (const [cx, cy] of corners) out.push(a.x, a.y, a.z, b.x, b.y, b.z, cx, cy);
672
+ for (const [cx, cy] of corners) {
673
+ out[o++] = a.x; out[o++] = a.y; out[o++] = a.z;
674
+ out[o++] = b.x; out[o++] = b.y; out[o++] = b.z;
675
+ out[o++] = cx; out[o++] = cy;
676
+ }
583
677
  }
584
678
  return out;
585
679
  }
package/trid/scene.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { CanvasRenderer, DEFAULT_LIGHT, DEFAULT_AMBIENT } from "./render/canvasRenderer.js";
2
2
  import { loop } from "./render/loop.js";
3
3
  import { ToolManager } from "./tools/manager.js";
4
+ import { screenshot, record } from "./export/capture.js";
4
5
 
5
6
  /**
6
7
  * Holds a canvas, a camera and a list of objects, and draws them.
@@ -150,6 +151,28 @@ export class Scene {
150
151
  this._stop = null;
151
152
  }
152
153
 
154
+ /**
155
+ * Draws a new frame and returns it as an image file (PNG by default).
156
+ * The page's canvas doesn't change. Save it with `download`.
157
+ *
158
+ * @param {import("./export/capture.js").ScreenshotOptions} [opts]
159
+ * @returns {Promise<Blob>}
160
+ */
161
+ screenshot(opts) {
162
+ return screenshot(this, opts);
163
+ }
164
+
165
+ /**
166
+ * Starts recording the canvas as a video. Call `stop()` on the result,
167
+ * or pass `duration`, then save `await recording.done` with `download`.
168
+ *
169
+ * @param {import("./export/capture.js").RecordOptions} [opts]
170
+ * @returns {import("./export/capture.js").Recording}
171
+ */
172
+ record(opts) {
173
+ return record(this, opts);
174
+ }
175
+
153
176
  /**
154
177
  * Stops the loop, removes every tool (and its event listeners) and
155
178
  * frees the renderer's resources. Use it when the scene is no longer needed.
@@ -54,7 +54,8 @@ export class HoverHighlight {
54
54
  obj.color = this.color;
55
55
  // Swap in a copy, so other objects sharing the material don't change
56
56
  this._savedMaterial = obj.material;
57
- if (obj.material) obj.material = { ...obj.material, fill: this.color };
57
+ if (Array.isArray(obj.material)) obj.material = obj.material.map((m) => m && { ...m, fill: this.color });
58
+ else if (obj.material) obj.material = { ...obj.material, fill: this.color };
58
59
  }
59
60
 
60
61
  this.onChange?.(obj);
@@ -13,6 +13,9 @@ const MAX_PITCH = Math.PI / 2 - 0.01;
13
13
  * The tool drives `scene.camera` (x, y, z, yaw, pitch). Its own
14
14
  * `target`, `distance`, `yaw` and `pitch` can be changed at any time.
15
15
  * Don't use it together with another tool that moves the camera.
16
+ *
17
+ * With `autoRotate: true` the camera also turns around the target on its
18
+ * own, pausing while the user drags.
16
19
  */
17
20
  export class OrbitCamera {
18
21
  /**
@@ -28,6 +31,9 @@ export class OrbitCamera {
28
31
  * @param {boolean} [opts.rotate=true] - Allow orbiting
29
32
  * @param {boolean} [opts.pan=true] - Allow panning
30
33
  * @param {boolean} [opts.zoom=true] - Allow zooming
34
+ * @param {boolean} [opts.autoRotate=false] - Turn around the target on its own (paused while dragging)
35
+ * @param {number} [opts.autoRotateSpeed=0.5] - Auto-rotate speed in radians per second (negative: other way)
36
+ * @param {number} [opts.autoRotateDelay=0] - Seconds to wait after a drag before auto-rotating again
31
37
  */
32
38
  constructor({
33
39
  target = { x: 0, y: 0, z: 0 },
@@ -41,6 +47,9 @@ export class OrbitCamera {
41
47
  rotate = true,
42
48
  pan = true,
43
49
  zoom = true,
50
+ autoRotate = false,
51
+ autoRotateSpeed = 0.5,
52
+ autoRotateDelay = 0,
44
53
  } = {}) {
45
54
  this.target = target;
46
55
  this.distance = distance;
@@ -53,9 +62,13 @@ export class OrbitCamera {
53
62
  this.rotate = rotate;
54
63
  this.pan = pan;
55
64
  this.zoom = zoom;
65
+ this.autoRotate = autoRotate;
66
+ this.autoRotateSpeed = autoRotateSpeed;
67
+ this.autoRotateDelay = autoRotateDelay;
56
68
 
57
69
  this.blockContextMenu = true;
58
70
  this._mode = null;
71
+ this._idle = 0; // seconds since the last drag ended
59
72
  }
60
73
 
61
74
  attach(scene) {
@@ -117,6 +130,7 @@ export class OrbitCamera {
117
130
 
118
131
  pointerUp() {
119
132
  this._mode = null;
133
+ this._idle = 0;
120
134
  }
121
135
 
122
136
  wheel(e, scene) {
@@ -127,6 +141,10 @@ export class OrbitCamera {
127
141
  }
128
142
 
129
143
  update(dt, scene) {
144
+ if (this.autoRotate && !this._mode) {
145
+ this._idle += dt;
146
+ if (this._idle >= this.autoRotateDelay) this.yaw += this.autoRotateSpeed * dt;
147
+ }
130
148
  this.apply(scene.camera);
131
149
  }
132
150
  }