@es3d/es3d 1.1.1 → 1.1.2

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/docs/SPEC.md CHANGED
@@ -195,7 +195,7 @@ The reference reader keeps up to four levels. At quality 1 the segment count is
195
195
 
196
196
  A `segments` attribute (or both torus segment counts) requests one fixed tessellation, which is part of the shape. A `segments="6"` cylinder is a hexagonal prism at every distance. Exact shapes (box, plane, polygon, hairline, authored mesh) have one level and a bound of 0.
197
197
 
198
- Each level stores its bound. A frame may draw a level only while `bound × scale × pixelsPerUnit` is at most 1. `scale` is the longest column of the instance matrix. `pixelsPerUnit` is the projected size of one canonical unit. The reference reader picks the coarsest level that satisfies that test. A renderer `lodBias` above 1 (default 1) divides the one-pixel budget; it is not a document attribute. While a level is still being generated, a renderer may draw a coarser one in its place for a few frames (progressive refinement), as long as it keeps drawing until every level passes the test. For a unit sphere the switches fall near 59, 15 and 7 pixels of the sphere's own radius.
198
+ Each level stores its bound. A frame may draw a level only while `bound × scale × pixelsPerUnit` is at most 1. `scale` is the longest column of the instance matrix. `pixelsPerUnit` is the largest projected size, in CSS pixels, of a unit length at the centre of the instance's bounding sphere. For a perspective view it is F·d/z², where F = viewportHeight / (2·tan(verticalFov/2)), d is the distance from the eye to the centre and z is its depth along the view axis; d²/z² is limited to its value at the corners of the view, and that limit is used when z ≤ 0. For an orthographic view it is viewportHeight divided by the view's height. The reference reader picks the coarsest level that satisfies that test. A renderer `lodBias` above 1 (default 1) divides the one-pixel budget; it is not a document attribute. While a level is still being generated, a renderer may draw a coarser one in its place for a few frames (progressive refinement), as long as it keeps drawing until every level passes the test. For a unit sphere on the view axis the switches fall near 59, 15 and 7 pixels of the sphere's own radius.
199
199
 
200
200
  Closed shapes must produce closed, outward-facing surfaces. Open or flat shapes (planes, polygons, paths, open revolves, `caps="false"`) are rendered double-sided.
201
201
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@es3d/es3d",
3
- "version": "1.1.1",
3
+ "version": "1.1.2",
4
4
  "description": "ES3D (Extensible Scalable 3D): a declarative, SVG-inspired 3D scene format with a WebGPU/WebGL2 reference renderer, editor and converter.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -21,6 +21,41 @@ function length3(x, y, z) {
21
21
  return Math.sqrt(x * x + y * y + z * z);
22
22
  }
23
23
 
24
+ /**
25
+ * Per-frame constants for `instancePixelsPerUnit`.
26
+ * `ppuFactor` is F = viewportHeight / (2 tan(verticalFov / 2)) for a perspective
27
+ * camera, or the constant scale for an orthographic one. `cornerStretch` is
28
+ * 1/cos^2 of the angle to the frustum's corner.
29
+ */
30
+ export function lodFrameConstants(camera, viewportHeight) {
31
+ const aspect = camera.aspect || 1;
32
+ const tanV = Math.tan(camera.verticalFov(aspect) / 2);
33
+ return {
34
+ orthographic: camera.type === 'orthographic',
35
+ ppuFactor: camera.pixelsPerUnit(1, viewportHeight),
36
+ view: camera.view,
37
+ cornerStretch: 1 + tanV * tanV * (1 + aspect * aspect),
38
+ };
39
+ }
40
+
41
+ /**
42
+ * Largest projected size, in CSS pixels, of a unit length at the centre
43
+ * (cx, cy, cz) of an instance's bounding sphere; `d` is the distance from the
44
+ * eye to that centre. A flat image plane stretches a displacement at angle a
45
+ * from the view axis by up to 1/cos^2(a) = d^2/z^2 (z = depth along the axis)
46
+ * radially. Inside the view that never exceeds its value at the frustum's
47
+ * corners, which is also used when the centre is at or behind the eye plane
48
+ * while the sphere is still visible. Orthographic views do not stretch.
49
+ * Allocation-free: it runs once per instance.
50
+ */
51
+ export function instancePixelsPerUnit(k, cx, cy, cz, d) {
52
+ if (k.orthographic) return k.ppuFactor;
53
+ const V = k.view;
54
+ const z = -(V[2] * cx + V[6] * cy + V[10] * cz + V[14]);
55
+ const stretch = z > 0 ? Math.min((d * d) / (z * z), k.cornerStretch) : k.cornerStretch;
56
+ return (k.ppuFactor * stretch) / Math.max(d, 1e-6);
57
+ }
58
+
24
59
  /**
25
60
  * The level to draw while `lod` is not generated yet: the nearest coarser
26
61
  * level that exists, else the nearest finer one, else the coarsest level
@@ -159,8 +194,8 @@ export class FrameBuilder {
159
194
  const vh = p.viewportHeight;
160
195
  const bias = options.lodBias || 1;
161
196
  const fixedLod = options.autoLod === false ? options.fixedLod || 0 : -1;
162
- // pixels per world unit at distance 1 (perspective) or everywhere (orthographic)
163
- const ppuFactor = camera.pixelsPerUnit(1, vh);
197
+ // pixels per world unit: depth based for perspective, constant for orthographic
198
+ const lodK = lodFrameConstants(camera, vh);
164
199
 
165
200
  for (let i = 0; i < n; i++) {
166
201
  keys[i] = -1;
@@ -189,7 +224,7 @@ export class FrameBuilder {
189
224
  else {
190
225
  const dx = cx - camPos[0], dy = cy - camPos[1], dz = cz - camPos[2];
191
226
  const d = length3(dx, dy, dz);
192
- const ppu = camera.type === 'orthographic' ? ppuFactor : ppuFactor / Math.max(d, 1e-6);
227
+ const ppu = instancePixelsPerUnit(lodK, cx, cy, cz, d);
193
228
  lod = selectLod(entry.lodDeviation, entry.lods, axisScale(scene.matrices, i), ppu, bias);
194
229
  }
195
230
  }