postext-folio 0.5.3 → 0.6.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/dist/pageFlip.js CHANGED
@@ -1,6 +1,6 @@
1
- import { BoxGeometry, BufferAttribute, Color, CustomBlending, DirectionalLight, DoubleSide, Group, HalfFloatType, LinearMipmapLinearFilter, MaxEquation, Mesh, MeshPhysicalMaterial, MeshStandardMaterial, LinearFilter, NeutralToneMapping, OneFactor, OrthographicCamera, PCFShadowMap, PerspectiveCamera, Plane, PlaneGeometry, PMREMGenerator, Raycaster, Scene, ShaderChunk, ShaderMaterial, ShadowMaterial, SRGBColorSpace, Texture, TextureLoader, Vector2, Vector3, Vector4, WebGLRenderer, WebGLRenderTarget, } from "three";
1
+ import { BoxGeometry, BufferAttribute, BufferGeometry, Color, CustomBlending, DepthTexture, DirectionalLight, DoubleSide, Group, HalfFloatType, LinearMipmapLinearFilter, MaxEquation, Mesh, MeshPhysicalMaterial, MeshStandardMaterial, LinearFilter, Matrix4, NeutralToneMapping, OneFactor, OrthographicCamera, PCFShadowMap, PerspectiveCamera, Plane, PlaneGeometry, PMREMGenerator, Raycaster, Scene, ShaderChunk, ShaderMaterial, ShadowMaterial, SRGBColorSpace, Texture, TextureLoader, Vector2, Vector3, Vector4, VideoTexture, WebGLRenderer, WebGLRenderTarget, } from "three";
2
2
  import { resolveFolioConfig } from "postext";
3
- import { along, BINDINGS, gutterOcclusion, profiles, stackGeometry } from "./bookGeometry";
3
+ import { along, BINDINGS, gutterOcclusion, profiles, stackGeometry, topAt } from "./bookGeometry";
4
4
  import { environment } from "./environments";
5
5
  import { pagePaper, paperSpec } from "./paper";
6
6
  import { loadDeskMaps } from "./deskTextures";
@@ -68,12 +68,17 @@ const OCCLUSION = /* glsl */ `
68
68
  // How far the reflection seen at p runs under paper: its ray marched
69
69
  // out through the height map (a lifted leaf hides what it would mirror,
70
70
  // and so does the leaf's own roll over the inside of its curl). The
71
- // march starts a few px out, past the paper round p itself.
71
+ // march starts a few px out, past the paper round p itself. Its steps
72
+ // are shifted a little from pixel to pixel: at fixed steps the leaf's
73
+ // outline was cut in at each of them, a row of teeth (#483).
72
74
  float reflectionOcclusion(vec3 p, vec3 r, float channel) {
73
75
  if (uShadowOn < 0.5) return 0.0;
76
+ // Interleaved gradient noise: neighbours take evenly spread shifts.
77
+ float spin = fract(52.9829189 * fract(dot(gl_FragCoord.xy, vec2(0.06711056, 0.00583715))));
74
78
  float hidden = 0.0;
75
- for (int i = 1; i <= 6; i++) {
76
- float t = 6.0 + float(i * i) / 36.0 * uOccRadius * 2.0;
79
+ for (int i = 0; i < 8; i++) {
80
+ float s = (float(i) + spin) / 8.0;
81
+ float t = 6.0 + s * s * uOccRadius * 2.0;
77
82
  vec3 q = p + r * t;
78
83
  hidden = max(hidden, smoothstep(0.0, 4.0, casterAt(q.xy, channel) - q.z - uOccBias));
79
84
  }
@@ -169,6 +174,25 @@ function keySpecular(occlusion = "1.0") {
169
174
  function keyShadowed(fragmentShader) {
170
175
  return fragmentShader.replace("#include <shadowmap_pars_fragment>", SHADOW_PARS);
171
176
  }
177
+ /** A caster's own copy of a mesh's shape. The height map is drawn before
178
+ * the main view, and three.js uploads a geometry once per frame number,
179
+ * which the key's shadow pass at the end of the last main view had
180
+ * already taken: a caster sharing a leaf's geometry drew the leaf where
181
+ * it lay a frame before, and a leaf moving down was shaded by its own
182
+ * old self, moving up it was not (#488). */
183
+ function casterGeometry(source) {
184
+ const g = new BufferGeometry();
185
+ g.setAttribute("position", source.attributes.position.clone());
186
+ if (source.index)
187
+ g.setIndex(source.index.clone());
188
+ return g;
189
+ }
190
+ /** Brings a caster's copy to its mesh's shape as laid out this frame. */
191
+ function syncCaster(caster, source) {
192
+ const to = caster.geometry.attributes.position;
193
+ to.array.set(source.attributes.position.array);
194
+ to.needsUpdate = true;
195
+ }
172
196
  function casterMaterial(leaf) {
173
197
  return new ShaderMaterial({
174
198
  vertexShader: CASTER_VERTEX,
@@ -198,6 +222,21 @@ function occluded(m, sign) {
198
222
  };
199
223
  return m;
200
224
  }
225
+ /** What lies behind the leaves in the air (#484): the scene drawn without
226
+ * the translucent leaves (colour, mipmapped, and depth), shared by every
227
+ * page. `uBehindSize`: the screen in device px; `uBehindClip`: the
228
+ * camera's near and far planes; `uBehindScale`: texels of that picture
229
+ * per book unit at a view depth of 1; `uBehindFloor`: how far (book
230
+ * units) the sheet's fibres spread even what touches it. */
231
+ const behindUniforms = {
232
+ uBehind: { value: null },
233
+ uBehindDepth: { value: null },
234
+ uBehindOn: { value: 0 },
235
+ uBehindSize: { value: new Vector2(1, 1) },
236
+ uBehindClip: { value: new Vector2(1, 2) },
237
+ uBehindScale: { value: 1 },
238
+ uBehindFloor: { value: 1 },
239
+ };
201
240
  /**
202
241
  * A page's material: three.js's physical (PBR) material lit by the
203
242
  * environment and the key light, the printed page on each face, the
@@ -216,13 +255,27 @@ function pageMaterial(mirror, sign) {
216
255
  uHasBack: { value: 0 },
217
256
  uMirror: { value: mirror ? 1 : 0 },
218
257
  uShowThrough: { value: 0 },
258
+ uUnder: { value: null },
259
+ uHasUnder: { value: 0 },
260
+ uUnderK: { value: 0 },
261
+ uTransmit: { value: 0 },
219
262
  uPaper: { value: new Color(1, 1, 1) },
220
263
  uLeafOcc: { value: 0.45 },
221
264
  uOccBias: { value: 6 },
265
+ uVideo: { value: null },
266
+ uVidFace: { value: 0 },
267
+ uVidO: { value: new Vector2() },
268
+ uVidA: { value: new Vector2(1, 0) },
269
+ uVidB: { value: new Vector2(0, 1) },
270
+ uVidCrop: { value: new Vector4(0, 0, 1, 1) },
271
+ uReflection: { value: null },
272
+ uReflectionOn: { value: 0 },
273
+ uReflectionSize: { value: new Vector2(1, 1) },
274
+ uReflectionNormal: { value: new Vector3(0, 0, 1) },
222
275
  };
223
276
  m.userData.uniforms = uniforms;
224
277
  m.onBeforeCompile = (shader) => {
225
- Object.assign(shader.uniforms, occlusionUniforms, uniforms, { uSign: { value: sign } });
278
+ Object.assign(shader.uniforms, occlusionUniforms, behindUniforms, uniforms, { uSign: { value: sign } });
226
279
  shader.vertexShader = shader.vertexShader
227
280
  .replace("#include <common>", `#include <common>
228
281
  attribute float ao;
@@ -241,9 +294,33 @@ function pageMaterial(mirror, sign) {
241
294
  uniform float uHasBack;
242
295
  uniform float uMirror;
243
296
  uniform float uShowThrough;
297
+ uniform sampler2D uUnder;
298
+ uniform float uHasUnder;
299
+ uniform float uUnderK;
300
+ uniform float uTransmit;
301
+ uniform sampler2D uBehind;
302
+ uniform sampler2D uBehindDepth;
303
+ uniform float uBehindOn;
304
+ uniform vec2 uBehindSize;
305
+ uniform vec2 uBehindClip;
306
+ uniform float uBehindScale;
307
+ uniform float uBehindFloor;
308
+ // The print of both faces at this point: what light coming
309
+ // through the sheet passes on its way.
310
+ vec3 transInk = vec3(1.0);
244
311
  uniform vec3 uPaper;
245
312
  uniform float uLeafOcc;
246
313
  uniform float uSign;
314
+ uniform sampler2D uVideo;
315
+ uniform float uVidFace;
316
+ uniform vec2 uVidO;
317
+ uniform vec2 uVidA;
318
+ uniform vec2 uVidB;
319
+ uniform vec4 uVidCrop;
320
+ uniform sampler2D uReflection;
321
+ uniform float uReflectionOn;
322
+ uniform vec2 uReflectionSize;
323
+ uniform vec3 uReflectionNormal;
247
324
  varying float vAo;
248
325
  varying vec2 vPageUv;
249
326
  varying vec3 vBookPos;
@@ -257,12 +334,34 @@ function pageMaterial(mirror, sign) {
257
334
  vec2 uvB = vec2(1.0 - u, vPageUv.y);
258
335
  vec3 ink = uPaper;
259
336
  if (has > 0.5 && has < 1.5) ink = front ? texture2D(uFront, uvF).rgb : texture2D(uBack, uvB).rgb;
337
+ // A video playing on this face, printed over its poster: the
338
+ // point of the page (from its top left corner) in the picture's
339
+ // own frame, which may lie turned on the page.
340
+ if (uVidFace > 0.5 && (uVidFace < 1.5) == front) {
341
+ vec2 tuv = front ? uvF : uvB;
342
+ vec2 p = vec2(tuv.x, 1.0 - tuv.y) - uVidO;
343
+ vec2 st = vec2(dot(p, uVidA) / dot(uVidA, uVidA), dot(p, uVidB) / dot(uVidB, uVidB));
344
+ if (st.x >= 0.0 && st.x <= 1.0 && st.y >= 0.0 && st.y <= 1.0) {
345
+ vec2 v = uVidCrop.xy + st * uVidCrop.zw;
346
+ ink = texture2D(uVideo, vec2(v.x, 1.0 - v.y)).rgb;
347
+ }
348
+ }
260
349
  // The other side's print, seen through the sheet (the same point
261
350
  // of paper, so mirrored as it should be).
262
351
  float other = front ? uHasBack : uHasFront;
263
- if (uShowThrough > 0.0 && other > 0.5 && other < 1.5) {
264
- vec3 through = front ? texture2D(uBack, uvB).rgb : texture2D(uFront, uvF).rgb;
265
- ink *= mix(vec3(1.0), through, uShowThrough);
352
+ vec3 through = vec3(1.0);
353
+ if ((uShowThrough > 0.0 || uTransmit > 0.0) && other > 0.5 && other < 1.5) {
354
+ through = front ? texture2D(uBack, uvB).rgb : texture2D(uFront, uvF).rgb;
355
+ }
356
+ transInk = ink * through;
357
+ if (uShowThrough > 0.0) ink *= mix(vec3(1.0), through, uShowThrough);
358
+ // The page lying under an open page: its print comes through the
359
+ // whole sheet (the fibres spread it by a fraction of a millimetre,
360
+ // finer than the page's own texels: a softer sample would blur
361
+ // a page seen small to grey).
362
+ if (uHasUnder > 0.5 && uUnderK > 0.0) {
363
+ vec3 under = texture2D(uUnder, front ? uvF : uvB).rgb;
364
+ ink *= mix(vec3(1.0), under, uUnderK);
266
365
  }
267
366
  diffuseColor.rgb *= ink;
268
367
  }`)
@@ -277,7 +376,49 @@ function pageMaterial(mirror, sign) {
277
376
  // The reflection's ray, in book coordinates.
278
377
  vec3 reflected = (vec4(reflect(-geometryViewDir, normal), 0.0) * viewMatrix).xyz;
279
378
  reflected.x *= uSign;
280
- ${keySpecular("1.0 - 0.85 * reflectionOcclusion(vBookPos, reflected, 1.0)")}`);
379
+ // A mirrored leaf stands for what it hides itself.
380
+ ${keySpecular("1.0 - 0.85 * (uReflectionOn > 0.5 ? 0.0 : reflectionOcclusion(vBookPos, reflected, 1.0))")}
381
+ #ifdef USE_CLEARCOAT
382
+ if (uReflectionOn > 0.5) {
383
+ // The leaves in the air, mirrored in a gloss page: where one is
384
+ // seen, it takes the place of the environment it hides, through
385
+ // the coating's Fresnel and the paper's own. A plane holds only
386
+ // where the page lies in it: the mirror fades up the gutter.
387
+ vec2 st = gl_FragCoord.xy / uReflectionSize;
388
+ vec4 seen = texture2D(uReflection, vec2(1.0 - st.x, st.y));
389
+ float lies = smoothstep(0.97, 0.995, abs(dot((vec4(normalize(vNormal), 0.0) * viewMatrix).xyz, uReflectionNormal)));
390
+ float a = seen.a * lies;
391
+ vec3 leaf = seen.rgb * lies;
392
+ clearcoatSpecularIndirect = clearcoatSpecularIndirect * (1.0 - a)
393
+ + leaf * EnvironmentBRDF(geometryClearcoatNormal, geometryViewDir, material.clearcoatF0, material.clearcoatF90, material.clearcoatRoughness);
394
+ reflectedLight.indirectSpecular = reflectedLight.indirectSpecular * (1.0 - a)
395
+ + leaf * EnvironmentBRDF(geometryNormal, geometryViewDir, material.specularColor, material.specularF90, material.roughness);
396
+ }
397
+ #endif
398
+ if (uBehindOn > 0.5 && uTransmit > 0.0) {
399
+ // Thin paper in the air lets through the light behind it (#484):
400
+ // what lies there on screen, seen through the print of both
401
+ // faces. The sheet scatters what it lets through about evenly
402
+ // (near Lambertian), so a thing a gap behind it is spread over
403
+ // about twice that gap: sharp where the leaf touches the page
404
+ // under it, a soft glow a few millimetres off. A page close
405
+ // under it is lit only through the leaf, so its print comes
406
+ // back at about half the leaf's transmittance (1 − opacity, as
407
+ // the open page shows the one under it); what lies further, out
408
+ // of the leaf's shade, at the whole of it. Stronger and sharper,
409
+ // the page under a leaf about to land read as lying over it
410
+ // (#492). What it lets through it does not reflect.
411
+ vec2 st = gl_FragCoord.xy / uBehindSize;
412
+ float depth = texture2D(uBehindDepth, st).x;
413
+ float zBehind = uBehindClip.x * uBehindClip.y / (uBehindClip.y - depth * (uBehindClip.y - uBehindClip.x));
414
+ float gap = max(0.0, zBehind - vViewPosition.z);
415
+ float spread = (uBehindFloor + 2.0 * gap) * uBehindScale / vViewPosition.z;
416
+ vec4 seen = textureLod(uBehind, st, log2(max(1.0, 2.0 * spread)));
417
+ float carried = uTransmit * mix(0.5, 1.0, smoothstep(0.0, 8.0 * uBehindFloor, gap));
418
+ float lost = carried * seen.a;
419
+ reflectedLight.directDiffuse *= 1.0 - lost;
420
+ reflectedLight.indirectDiffuse = reflectedLight.indirectDiffuse * (1.0 - lost) + carried * seen.rgb * transInk;
421
+ }`);
281
422
  };
282
423
  return m;
283
424
  }
@@ -645,6 +786,13 @@ export function layLeaf(geometry, fold, forward, W, H, lift, book, rigidity, q,
645
786
  if (amp > 1e-6)
646
787
  pz += amp * smooth(0, 2 * amp, clear) * Math.sin(2 * Math.PI * (time / 520) - (3 * u) / W + (2 * v) / H);
647
788
  }
789
+ // Never into the book: over a block (and past its head or foot) a
790
+ // leaf stands at least just over its top. A fold's flap is carried
791
+ // level from the foot of its fold, and on a thin side hanging from
792
+ // the spine that foot lies low: the flap went through the tall block
793
+ // on the other side (#489).
794
+ if (!board && Math.abs(px) < (px >= 0 ? xr : xl))
795
+ pz = Math.max(pz, topAt(px >= 0 ? book.right : book.left, Math.abs(px)) + Math.min(lift, 0.5));
648
796
  pos.setXYZ(iy * (NX + 1) + ix, px, y, pz);
649
797
  }
650
798
  }
@@ -660,9 +808,25 @@ function disposeLeaf(mesh) {
660
808
  parts.slab.geometry.dispose();
661
809
  parts.slab.material.dispose();
662
810
  }
811
+ mesh.userData.caster?.geometry.dispose();
663
812
  mesh.geometry.dispose();
664
813
  mesh.material.dispose();
665
814
  }
815
+ /**
816
+ * The plane an open page lies in past the gutter's shoulder (world,
817
+ * normal up), its side running out from the spine along world x as
818
+ * `out` (±1): level on a block lying on the desk, sloping down to it on
819
+ * the thin side of a thick book, which hangs from the top of the spine.
820
+ * A gloss page mirrors the leaves in the air in it (#483).
821
+ */
822
+ export function pagePlane(p, W, out) {
823
+ const [x0, z0] = along(p, W * 0.55);
824
+ const [x1, z1] = along(p, W);
825
+ const dx = out * (x1 - x0);
826
+ const dz = z1 - z0;
827
+ const normal = new Vector3(-dz * Math.sign(dx || 1), 0, Math.abs(dx)).normalize();
828
+ return new Plane().setFromNormalAndCoplanarPoint(normal, new Vector3(out * x0, 0, z0));
829
+ }
666
830
  /**
667
831
  * Turns the pages of the viewer's DOM spread in a 3D scene: the book lies
668
832
  * open on a desk, seen from in front and above (tilted by `folio.tilt`),
@@ -703,6 +867,12 @@ export class PageFlipper {
703
867
  spineSrc = null;
704
868
  spineTex = null;
705
869
  spineFaces = [];
870
+ /** The book's own cover folded round the spine while it is in the air:
871
+ * a strip on the spine's outer side, up to where the cover leaves it.
872
+ * `top`: the back of the block's height; `back`: its thickness; `side`:
873
+ * the spine's outer side (the empty stack's, −1 left). */
874
+ hinge;
875
+ fold = { top: 0, back: 0, side: -1 };
706
876
  edges;
707
877
  key = new DirectionalLight(0xffffff, 1);
708
878
  pmrem = null;
@@ -716,6 +886,16 @@ export class PageFlipper {
716
886
  shadowScene = new Scene();
717
887
  shadowCamera = new OrthographicCamera(-1, 1, 1, -1, 1, 4000);
718
888
  shadowTarget = null;
889
+ /** The leaves in the air mirrored in each gloss open page (#483), and
890
+ * the camera that sees them so (the view mirrored in the page). */
891
+ mirrors = { left: null, right: null };
892
+ mirrorCamera = new PerspectiveCamera();
893
+ mirrorOk = false;
894
+ /** What lies behind the translucent leaves in the air (#484). */
895
+ behindTarget = null;
896
+ /** The drawing buffer (device px) and the device pixel ratio. */
897
+ buffer = new Vector2(1, 1);
898
+ dpr = 1;
719
899
  /** Draw the leaves in the air, and the rest of the book, into the
720
900
  * height map. */
721
901
  leafCaster;
@@ -829,11 +1009,16 @@ export class PageFlipper {
829
1009
  depthBuffer: false,
830
1010
  generateMipmaps: false,
831
1011
  });
1012
+ this.mirrorOk = true;
832
1013
  }
1014
+ this.mirrorCamera.matrixAutoUpdate = false;
833
1015
  // A mirror turns three.js's face culling round with it (the front of a
834
1016
  // page stays its front).
835
1017
  this.stage.scale.x = this.sign;
836
- this.stage.add(this.desk, this.covers, this.left, this.right, this.key, this.key.target);
1018
+ this.hinge = new Mesh(new BoxGeometry(1, 1, 1), this.coverMaterial);
1019
+ this.hinge.receiveShadow = true;
1020
+ this.hinge.visible = false;
1021
+ this.stage.add(this.desk, this.covers, this.left, this.right, this.hinge, this.key, this.key.target);
837
1022
  this.scene.add(this.stage);
838
1023
  this.target = at;
839
1024
  this.reported = at;
@@ -1065,7 +1250,17 @@ export class PageFlipper {
1065
1250
  const coated = p.finish !== "uncoated";
1066
1251
  const s = p.textureStrength * (coated ? 0.35 : 1) * 0.6;
1067
1252
  m.normalScale.set(s, s);
1068
- m.userData.uniforms.uShowThrough.value = spec.showThrough;
1253
+ const u = m.userData.uniforms;
1254
+ u.uShowThrough.value = spec.showThrough;
1255
+ // An open page shows the page under it; a leaf in the air, what is
1256
+ // behind it (a block of leaves turning together is opaque).
1257
+ const open = mesh === this.left || mesh === this.right;
1258
+ // Print under a sheet shows through at 1 − opacity (what opacity
1259
+ // measures), half the light the sheet lets through: as much as a
1260
+ // leaf lifting off it lets through close over the page, so nothing
1261
+ // changes when the reader takes hold of it (#492).
1262
+ u.uUnderK.value = open ? spec.transmission / 2 : 0;
1263
+ u.uTransmit.value = open || mesh === this.blockMesh ? 0 : spec.transmission;
1069
1264
  m.needsUpdate = true;
1070
1265
  }
1071
1266
  // ── Layout ──
@@ -1264,6 +1459,7 @@ export class PageFlipper {
1264
1459
  // book's own cover), no more.
1265
1460
  const back = Math.min(0.04 * W, Math.max(1.5, (this.coverSpec?.caliperMm ?? BINDINGS[binding].boardMm) * k));
1266
1461
  this.clearSpine();
1462
+ this.fold = { top: meet > 0.4 ? meet - 0.3 : 0, back, side: tL < tR ? -1 : 1 };
1267
1463
  if (meet > 0.4) {
1268
1464
  // In a case the back is wrapped in the case's spine (the wall beside
1269
1465
  // an empty side, the headcap at head and tail); without one, the
@@ -1314,10 +1510,12 @@ export class PageFlipper {
1314
1510
  tex.repeat.set(W / k / tile, H / k / tile);
1315
1511
  }
1316
1512
  // The book at rest in the height map.
1317
- for (const c of this.staticCasters)
1513
+ for (const c of this.staticCasters) {
1318
1514
  c.removeFromParent();
1515
+ c.geometry.dispose();
1516
+ }
1319
1517
  this.staticCasters = [this.left, this.right, ...this.stacks, ...this.covers.children].map((m) => {
1320
- const c = new Mesh(m.geometry, this.staticCaster);
1518
+ const c = new Mesh(casterGeometry(m.geometry), this.staticCaster);
1321
1519
  c.position.copy(m.position);
1322
1520
  c.frustumCulled = false;
1323
1521
  this.shadowScene.add(c);
@@ -1357,6 +1555,8 @@ export class PageFlipper {
1357
1555
  const dpr = Math.min(window.devicePixelRatio || 1, 3);
1358
1556
  this.renderer.setPixelRatio(1);
1359
1557
  this.renderer.setSize(Math.round(cw * dpr), Math.round(ch * dpr), false);
1558
+ this.buffer.set(Math.round(cw * dpr), Math.round(ch * dpr));
1559
+ this.dpr = dpr;
1360
1560
  const spread = this.spread.getBoundingClientRect();
1361
1561
  const W = spread.width / 2 || 1;
1362
1562
  const H = spread.height || 1;
@@ -1532,6 +1732,76 @@ export class PageFlipper {
1532
1732
  mesh.material.userData.uniforms.uPaper.value.setRGB(r, g, b, SRGBColorSpace);
1533
1733
  this.redraw();
1534
1734
  }
1735
+ /** The video playing on a page (#477), or none. */
1736
+ video = null;
1737
+ /**
1738
+ * Draws a video on the page whose source is `src` (see
1739
+ * {@link PageVideoFrame}), over its painting, on whichever face of
1740
+ * whichever leaf carries that page: lying open or turning, the picture
1741
+ * bends with the paper. `null` takes it off. The book is drawn again at
1742
+ * every new frame of the video.
1743
+ */
1744
+ setVideo(video) {
1745
+ const old = this.video;
1746
+ if (old && (!video || video.element !== old.element)) {
1747
+ old.element.cancelVideoFrameCallback?.(old.callback);
1748
+ old.texture.dispose();
1749
+ this.video = null;
1750
+ }
1751
+ if (video) {
1752
+ let texture = this.video?.texture;
1753
+ if (!texture) {
1754
+ texture = new VideoTexture(video.element);
1755
+ texture.colorSpace = SRGBColorSpace;
1756
+ texture.minFilter = LinearFilter;
1757
+ texture.generateMipmaps = false;
1758
+ }
1759
+ this.video = { ...video, texture, callback: this.video?.callback ?? 0 };
1760
+ if (!this.video.callback)
1761
+ this.watchVideo();
1762
+ }
1763
+ for (const mesh of this.pageMeshes())
1764
+ this.dressVideo(mesh);
1765
+ this.redraw();
1766
+ }
1767
+ /** Draws the book again at each new frame of the video (a turning book
1768
+ * is drawn by its own frames). */
1769
+ watchVideo() {
1770
+ const v = this.video;
1771
+ if (!v)
1772
+ return;
1773
+ const el = v.element;
1774
+ const next = () => {
1775
+ if (this.video?.element !== el || this.disposed)
1776
+ return;
1777
+ this.redraw();
1778
+ v.callback = el.requestVideoFrameCallback ? el.requestVideoFrameCallback(next) : requestAnimationFrame(next);
1779
+ if (this.video)
1780
+ this.video.callback = v.callback;
1781
+ };
1782
+ v.callback = el.requestVideoFrameCallback ? el.requestVideoFrameCallback(next) : requestAnimationFrame(next);
1783
+ }
1784
+ /** The meshes that show pages: the open ones, the leaves in the air and
1785
+ * the block. */
1786
+ pageMeshes() {
1787
+ return [this.left, this.right, ...this.leaves.values(), ...(this.blockMesh ? [this.blockMesh] : [])];
1788
+ }
1789
+ /** Sets a mesh's video uniforms: on the face that shows the video's
1790
+ * page, off elsewhere. */
1791
+ dressVideo(mesh) {
1792
+ const u = mesh.material.userData.uniforms;
1793
+ const v = this.video;
1794
+ const face = v && v.src ? (mesh.userData.Front === v.src ? 1 : mesh.userData.Back === v.src ? 2 : 0) : 0;
1795
+ u.uVidFace.value = face;
1796
+ u.uVideo.value = face && v ? v.texture : null;
1797
+ if (!face || !v)
1798
+ return;
1799
+ const { origin, across, down, crop } = v.frame;
1800
+ u.uVidO.value.set(origin.x, origin.y);
1801
+ u.uVidA.value.set(across.x, across.y);
1802
+ u.uVidB.value.set(down.x, down.y);
1803
+ u.uVidCrop.value.set(crop?.x ?? 0, crop?.y ?? 0, crop?.width ?? 1, crop?.height ?? 1);
1804
+ }
1535
1805
  /** A page source drawn again in place (a canvas repainted): its
1536
1806
  * texture is uploaded again and the book redrawn. */
1537
1807
  touch(src) {
@@ -1614,7 +1884,7 @@ export class PageFlipper {
1614
1884
  }
1615
1885
  const used = new Set(book.flat());
1616
1886
  for (const mesh of [this.left, this.right])
1617
- used.add(mesh.userData.Front).add(mesh.userData.Back);
1887
+ used.add(mesh.userData.Front).add(mesh.userData.Back).add(mesh.userData.Under);
1618
1888
  for (const [src, tex] of this.ready) {
1619
1889
  if (used.has(src))
1620
1890
  continue;
@@ -1629,6 +1899,7 @@ export class PageFlipper {
1629
1899
  if (mesh.userData[face] === src)
1630
1900
  return;
1631
1901
  mesh.userData[face] = src;
1902
+ this.dressVideo(mesh);
1632
1903
  u[`uHas${face}`].value = 0;
1633
1904
  u[`u${face}`].value = null;
1634
1905
  if (src === "")
@@ -1668,7 +1939,7 @@ export class PageFlipper {
1668
1939
  mesh.material.userData.uniforms.uPaper.value.setRGB(...this.paper, SRGBColorSpace);
1669
1940
  mesh.castShadow = mesh.receiveShadow = true;
1670
1941
  mesh.frustumCulled = false;
1671
- const caster = new Mesh(mesh.geometry, this.leafCaster);
1942
+ const caster = new Mesh(casterGeometry(mesh.geometry), this.leafCaster);
1672
1943
  caster.frustumCulled = false;
1673
1944
  caster.visible = false;
1674
1945
  this.shadowScene.add(caster);
@@ -1692,6 +1963,49 @@ export class PageFlipper {
1692
1963
  mm += this.specOf(k).caliperMm;
1693
1964
  return Math.max(0.5, mm * (this.appearance.singlePage ? 0.5 : 1) * this.pxPerMm());
1694
1965
  }
1966
+ /**
1967
+ * The book's own cover in the air is still folded round the spine: on
1968
+ * the spine's outer side its fold rises from the back of the block to
1969
+ * where the cover leaves the spine. Without it the edge of the page
1970
+ * under the cover showed between the two (a white line under a dark
1971
+ * cover), the cover being lifted off the pages at the spine too.
1972
+ */
1973
+ foldCover(air) {
1974
+ const covers = this.appearance.coverLeaves;
1975
+ const isCover = (k) => k === covers?.front || k === covers?.back;
1976
+ // The lowest the spine edge of a leaf's faces stands.
1977
+ const spineZ = (mesh) => {
1978
+ const parts = mesh.userData.board;
1979
+ let z = Infinity;
1980
+ for (const m of parts?.back.visible ? [mesh, parts.back] : [mesh]) {
1981
+ const pos = m.geometry.attributes.position;
1982
+ for (let iy = 0; iy <= NY; iy++)
1983
+ z = Math.min(z, pos.getZ(iy * (NX + 1)));
1984
+ }
1985
+ return z;
1986
+ };
1987
+ let at = Infinity;
1988
+ if (this.coverSpec) {
1989
+ for (const { k, mesh } of air)
1990
+ if (isCover(k))
1991
+ at = Math.min(at, spineZ(mesh));
1992
+ const block = this.block;
1993
+ if (block && this.blockMesh && [covers?.front, covers?.back].some((c) => c !== undefined && c >= block.lo && c < block.hi))
1994
+ at = Math.min(at, spineZ(this.blockMesh));
1995
+ }
1996
+ const { top, back, side } = this.fold;
1997
+ // Right under the cover, and a little in under it past the spine: a
1998
+ // slot left between the two showed the page under the cover, seen
1999
+ // low over the spine.
2000
+ const reach = at - 0.03;
2001
+ const inner = 0.3;
2002
+ this.hinge.visible = top > 0 && reach > top;
2003
+ if (!this.hinge.visible)
2004
+ return;
2005
+ const depth = reach - top + 0.05;
2006
+ this.hinge.scale.set(back / 2 + inner, this.H, depth);
2007
+ this.hinge.position.set((side * (back / 2 - inner)) / 2, 0, top - 0.05 + depth / 2);
2008
+ }
1695
2009
  /** A board leaf's other face and edges, carried with its mesh. */
1696
2010
  boardParts(mesh) {
1697
2011
  let parts = mesh.userData.board;
@@ -2173,6 +2487,7 @@ export class PageFlipper {
2173
2487
  if (!mesh.parent)
2174
2488
  this.stage.add(mesh);
2175
2489
  }
2490
+ this.foldCover(air);
2176
2491
  // The pages lying open: the top leaf of each stack.
2177
2492
  let l = -1;
2178
2493
  for (let j = 0; j < n; j++)
@@ -2189,12 +2504,21 @@ export class PageFlipper {
2189
2504
  // What shows through an open page: the other side of its leaf.
2190
2505
  this.assign(this.left, "Front", l >= 0 ? (this.book[l]?.[1] ?? null) : null);
2191
2506
  this.assign(this.right, "Back", this.book[r + 1]?.[0] ?? null);
2507
+ // And through the whole sheet, the page lying under it: the next
2508
+ // leaf's face on the same side.
2509
+ const lying = (j, left) => j >= 0 && j < n && this.turned[j] === left && !this.turns.has(j) && !this.inBlock(j);
2510
+ this.assign(this.left, "Under", lying(l - 1, true) ? (this.book[l]?.[0] ?? null) : null);
2511
+ this.assign(this.right, "Under", lying(r + 1, false) ? (this.book[r + 1]?.[1] ?? null) : null);
2192
2512
  const extra = this.appearance.extraLeaves;
2193
2513
  this.left.visible = leftSrc !== null || (extra?.before ?? 0) > 0;
2194
2514
  this.right.visible = rightSrc !== null || (extra?.after ?? 0) > 0;
2195
2515
  this.dressPage(this.left, l >= 0 ? this.specOf(l) : this.bookSpec);
2196
2516
  this.dressPage(this.right, r < n ? this.specOf(r) : this.bookSpec);
2197
- // The height map lifted leaves occlude the sky from.
2517
+ // The height map lifted leaves occlude the sky from, with the leaves
2518
+ // where they lie this frame.
2519
+ for (const mesh of [...this.leaves.values(), this.blockMesh])
2520
+ if (mesh?.visible)
2521
+ syncCaster(mesh.userData.caster, mesh.geometry);
2198
2522
  for (const c of this.staticCasters)
2199
2523
  c.visible = true;
2200
2524
  this.staticCasters[0].visible = this.left.visible;
@@ -2205,8 +2529,170 @@ export class PageFlipper {
2205
2529
  this.renderer.render(this.shadowScene, this.shadowCamera);
2206
2530
  this.renderer.setRenderTarget(null);
2207
2531
  }
2532
+ this.mirror(air.length > 0 || this.block !== null);
2533
+ this.behind(air.map((a) => a.mesh));
2208
2534
  this.renderer.render(this.scene, this.camera);
2209
2535
  }
2536
+ /**
2537
+ * Thin paper lets light through (#484). While translucent leaves are in
2538
+ * the air, the scene is drawn once more without them (at half the
2539
+ * screen's resolution: the sheet blurs what it lets through anyway),
2540
+ * colour and depth: each such leaf reads, at its own place on screen,
2541
+ * what lies behind it there and how far, and lets that light through.
2542
+ * They cast no shadow and take no sky from the pages in that picture:
2543
+ * a page under a thin leaf is lit through it, and a leaf just taken
2544
+ * hold of shows the page under it as it did lying on it. Nothing is
2545
+ * drawn at rest or on opaque stock.
2546
+ */
2547
+ behind(air) {
2548
+ const u = behindUniforms;
2549
+ u.uBehindOn.value = 0;
2550
+ u.uBehind.value = u.uBehindDepth.value = null;
2551
+ const lit = air.filter((m) => m.visible && m.material.userData.uniforms.uTransmit.value > 0);
2552
+ if (!this.mirrorOk || !lit.length)
2553
+ return;
2554
+ const w = Math.max(1, Math.round(this.buffer.x / 2));
2555
+ const h = Math.max(1, Math.round(this.buffer.y / 2));
2556
+ let target = this.behindTarget;
2557
+ if (!target) {
2558
+ target = new WebGLRenderTarget(w, h, { type: HalfFloatType, minFilter: LinearMipmapLinearFilter, magFilter: LinearFilter, generateMipmaps: true });
2559
+ target.depthTexture = new DepthTexture(w, h);
2560
+ this.behindTarget = target;
2561
+ }
2562
+ else if (target.width !== w || target.height !== h)
2563
+ target.setSize(w, h);
2564
+ for (const m of lit)
2565
+ m.visible = false;
2566
+ // The key's shadow map drawn again without them (the main view draws
2567
+ // it with them after), and the open pages' sky not cut by them.
2568
+ const shadows = this.renderer.shadowMap.autoUpdate;
2569
+ this.renderer.shadowMap.autoUpdate = false;
2570
+ this.renderer.shadowMap.needsUpdate = true;
2571
+ const occ = [this.left, this.right].map((m) => m.material.userData.uniforms.uLeafOcc);
2572
+ const was = occ.map((o) => o.value);
2573
+ for (const o of occ)
2574
+ o.value = 0;
2575
+ this.renderer.setRenderTarget(target);
2576
+ this.renderer.clear();
2577
+ this.renderer.render(this.scene, this.camera);
2578
+ this.renderer.setRenderTarget(null);
2579
+ this.renderer.shadowMap.autoUpdate = shadows;
2580
+ occ.forEach((o, i) => (o.value = was[i]));
2581
+ for (const m of lit)
2582
+ m.visible = true;
2583
+ u.uBehind.value = target.texture;
2584
+ u.uBehindDepth.value = target.depthTexture;
2585
+ u.uBehindSize.value.copy(this.buffer);
2586
+ u.uBehindClip.value.set(this.camera.near, this.camera.far);
2587
+ u.uBehindScale.value = (h / 2) * this.camera.projectionMatrix.elements[5];
2588
+ // Light spreads about a quarter of a millimetre in the sheet.
2589
+ u.uBehindFloor.value = 0.25 * this.pxPerMm();
2590
+ u.uBehindOn.value = 1;
2591
+ }
2592
+ /**
2593
+ * Gloss paper mirrors the leaves turning over it (#483). For each open
2594
+ * page on gloss stock, while anything is in the air, the leaves in the
2595
+ * air are drawn from the camera mirrored in that page's plane: a
2596
+ * camera under the page looking up at the leaves as they are, so each
2597
+ * is lit, shadowed and occluded as it is and shows the face the page
2598
+ * sees. Turned left for right, that picture lies on the page's own
2599
+ * place on screen; the page reads it as its reflection. Two pages at
2600
+ * the same height share one picture. Nothing is drawn at rest or on
2601
+ * any other finish.
2602
+ */
2603
+ mirror(airborne) {
2604
+ const sides = [
2605
+ { side: "left", page: this.left },
2606
+ { side: "right", page: this.right },
2607
+ ];
2608
+ let last = null;
2609
+ for (const { side, page } of sides) {
2610
+ const u = page.material.userData.uniforms;
2611
+ const on = this.mirrorOk && airborne && page.visible && page.material.userData.spec?.paper.finish === "gloss";
2612
+ u.uReflectionOn.value = on ? 1 : 0;
2613
+ if (!on)
2614
+ continue;
2615
+ // Stage x runs out from the spine, to the left on the left page,
2616
+ // and the stage is mirrored for a right-bound book.
2617
+ const plane = pagePlane(this.surfaces[side], this.W, (side === "left" ? -1 : 1) * this.sign);
2618
+ if (!last || last.plane.normal.dot(plane.normal) < 0.99999 || Math.abs(last.plane.constant - plane.constant) > 0.25) {
2619
+ const target = this.mirrorTarget(side);
2620
+ this.drawMirror(target, plane);
2621
+ last = { plane, target };
2622
+ }
2623
+ u.uReflection.value = last.target.texture;
2624
+ u.uReflectionSize.value.copy(this.buffer);
2625
+ u.uReflectionNormal.value.copy(plane.normal);
2626
+ }
2627
+ }
2628
+ /** A side's mirror picture, about as sharp as the screen (in CSS px
2629
+ * up to 1.5 device px each: a gloss coat is never a perfect mirror). */
2630
+ mirrorTarget(side) {
2631
+ const f = Math.min(1, 1.5 / this.dpr);
2632
+ const w = Math.max(1, Math.round(this.buffer.x * f));
2633
+ const h = Math.max(1, Math.round(this.buffer.y * f));
2634
+ let target = this.mirrors[side];
2635
+ if (!target) {
2636
+ target = new WebGLRenderTarget(w, h, { type: HalfFloatType, minFilter: LinearFilter, magFilter: LinearFilter, generateMipmaps: false, samples: 4 });
2637
+ this.mirrors[side] = target;
2638
+ }
2639
+ else if (target.width !== w || target.height !== h)
2640
+ target.setSize(w, h);
2641
+ return target;
2642
+ }
2643
+ /** Draws the leaves in the air as `plane` (an open page's) mirrors them. */
2644
+ drawMirror(target, plane) {
2645
+ const cam = this.mirrorCamera;
2646
+ // The eye mirrored in the plane, turned round its up axis so it is
2647
+ // still a camera (no mirror in it): its picture is the reflection
2648
+ // turned left for right, and its frustum the main one turned so too.
2649
+ const { x, y, z } = plane.normal;
2650
+ const d = -plane.constant;
2651
+ const reflect = new Matrix4().set(1 - 2 * x * x, -2 * x * y, -2 * x * z, 2 * d * x, -2 * y * x, 1 - 2 * y * y, -2 * y * z, 2 * d * y, -2 * z * x, -2 * z * y, 1 - 2 * z * z, 2 * d * z, 0, 0, 0, 1);
2652
+ cam.matrix.copy(reflect).multiply(this.camera.matrixWorld).multiply(new Matrix4().makeScale(-1, 1, 1));
2653
+ cam.updateMatrixWorld(true);
2654
+ const p = cam.projectionMatrix.copy(this.camera.projectionMatrix);
2655
+ p.elements[8] *= -1;
2656
+ // Its near plane laid along the page (an oblique frustum): nothing
2657
+ // under the page shows in its mirror.
2658
+ const near = new Plane(plane.normal.clone(), plane.constant + 0.2).applyMatrix4(cam.matrixWorldInverse);
2659
+ const clip = new Vector4(near.normal.x, near.normal.y, near.normal.z, near.constant);
2660
+ const e = p.elements;
2661
+ const q = new Vector4((Math.sign(clip.x) + e[8]) / e[0], (Math.sign(clip.y) + e[9]) / e[5], -1, (1 + e[10]) / e[14]);
2662
+ clip.multiplyScalar(2 / clip.dot(q));
2663
+ e[2] = clip.x;
2664
+ e[6] = clip.y;
2665
+ e[10] = clip.z + 1;
2666
+ e[14] = clip.w;
2667
+ cam.projectionMatrixInverse.copy(p).invert();
2668
+ // Only the leaves in the air (and the light on them).
2669
+ const keep = new Set([this.key, this.key.target, ...this.leaves.values()]);
2670
+ if (this.blockMesh)
2671
+ keep.add(this.blockMesh);
2672
+ const hidden = [];
2673
+ for (const c of this.stage.children) {
2674
+ if (keep.has(c) || !c.visible)
2675
+ continue;
2676
+ c.visible = false;
2677
+ hidden.push(c);
2678
+ }
2679
+ // The key's shadow map as the main view drew it last: the leaves'
2680
+ // own faces under them are not lit by the key anyway.
2681
+ const shadows = this.renderer.shadowMap.autoUpdate;
2682
+ this.renderer.shadowMap.autoUpdate = false;
2683
+ // The picture behind the leaves lies on the main view's screen, not
2684
+ // this one's.
2685
+ const behind = behindUniforms.uBehindOn.value;
2686
+ behindUniforms.uBehindOn.value = 0;
2687
+ this.renderer.setRenderTarget(target);
2688
+ this.renderer.clear();
2689
+ this.renderer.render(this.scene, cam);
2690
+ this.renderer.setRenderTarget(null);
2691
+ this.renderer.shadowMap.autoUpdate = shadows;
2692
+ behindUniforms.uBehindOn.value = behind;
2693
+ for (const c of hidden)
2694
+ c.visible = true;
2695
+ }
2210
2696
  /** Back to a transparent canvas (the DOM spread shows), unless leaves
2211
2697
  * are moving again. A persistent book is drawn at rest instead. */
2212
2698
  clear() {
@@ -2233,14 +2719,21 @@ export class PageFlipper {
2233
2719
  }
2234
2720
  dispose() {
2235
2721
  this.disposed = true;
2722
+ this.setVideo(null);
2236
2723
  cancelAnimationFrame(this.raf);
2237
2724
  cancelAnimationFrame(this.still);
2238
2725
  cancelAnimationFrame(this.orbitFrame);
2239
2726
  for (const tex of this.ready.values())
2240
2727
  tex.dispose();
2241
2728
  this.shadowTarget?.dispose();
2729
+ this.mirrors.left?.dispose();
2730
+ this.mirrors.right?.dispose();
2731
+ this.behindTarget?.depthTexture?.dispose();
2732
+ this.behindTarget?.dispose();
2242
2733
  this.leafCaster.dispose();
2243
2734
  this.staticCaster.dispose();
2735
+ for (const c of this.staticCasters)
2736
+ c.geometry.dispose();
2244
2737
  this.scene.environment?.dispose();
2245
2738
  this.pmrem?.dispose();
2246
2739
  for (const mesh of this.leaves.values())
@@ -2253,6 +2746,7 @@ export class PageFlipper {
2253
2746
  }
2254
2747
  for (const c of this.covers.children)
2255
2748
  c.geometry.dispose();
2749
+ this.hinge.geometry.dispose();
2256
2750
  this.clearSpine();
2257
2751
  this.spineTex?.dispose();
2258
2752
  this.coverMaterial.normalMap?.dispose();