Start texture-space book renderer

This commit is contained in:
2026-06-06 14:51:07 +02:00
parent 326f812b22
commit 62215b280f
6 changed files with 259 additions and 100 deletions
+90
View File
@@ -0,0 +1,90 @@
/**
* Book Page Format Module
* Defines the canonical page geometry used by the WebGL book renderer.
*/
import { BaseModule } from './base-module.js';
class BookPageFormatModule extends BaseModule {
constructor() {
super('book-page-format', 'Book Page Format');
this.dependencies = [];
this.format = Object.freeze({
id: 'us-mass-market-hardcover',
trim: Object.freeze({
widthIn: 4.25,
heightIn: 6.375
}),
margins: Object.freeze({
topIn: 0.46,
bottomIn: 0.58,
innerIn: 0.68,
outerIn: 0.46
}),
typography: Object.freeze({
fontFamily: '"EB Garamond", "EB Garamond 12", serif',
bodyFontSizePt: 10.8,
lineHeightPt: 14.9,
headingFontSizePt: 13.2,
dropCapLines: 2
})
});
this.bindMethods([
'getFormat',
'getAspectRatio',
'getTextureMetrics',
'inchesToTexture'
]);
}
async initialize() {
this.reportProgress(100, 'Book page format ready');
return true;
}
getFormat() {
return this.format;
}
getAspectRatio() {
return this.format.trim.widthIn / this.format.trim.heightIn;
}
inchesToTexture(valueIn, textureHeight) {
return (Number(valueIn) / this.format.trim.heightIn) * textureHeight;
}
getTextureMetrics(textureWidth = 1280) {
const width = Math.max(1, Math.round(Number(textureWidth) || 1280));
const height = Math.round(width / this.getAspectRatio());
const margins = {
top: this.inchesToTexture(this.format.margins.topIn, height),
bottom: this.inchesToTexture(this.format.margins.bottomIn, height),
inner: this.inchesToTexture(this.format.margins.innerIn, height),
outer: this.inchesToTexture(this.format.margins.outerIn, height)
};
return {
width,
height,
aspectRatio: this.getAspectRatio(),
margins,
content: {
x: margins.outer,
y: margins.top,
width: Math.max(1, width - margins.outer - margins.inner),
height: Math.max(1, height - margins.top - margins.bottom)
},
typography: this.format.typography
};
}
}
const bookPageFormat = new BookPageFormatModule();
export { bookPageFormat as BookPageFormat };
if (window.moduleRegistry) {
window.moduleRegistry.register(bookPageFormat);
}
window.BookPageFormat = bookPageFormat;
+139
View File
@@ -0,0 +1,139 @@
/**
* Book Texture Renderer Module
* Draws the virtual book pages directly into texture-space canvases.
*/
import { BaseModule } from './base-module.js';
class BookTextureRendererModule extends BaseModule {
constructor() {
super('book-texture-renderer', 'Book Texture Renderer');
this.dependencies = ['book-page-format', 'localization'];
this.pageFormat = null;
this.localization = null;
this.metrics = null;
this.canvases = {
left: null,
right: null
};
this.contexts = {
left: null,
right: null
};
this.hitMaps = {
left: [],
right: []
};
this.bindMethods([
'initialize',
'createPageCanvases',
'drawEmptySpread',
'drawPageBase',
'drawDebugText',
'publishSpread',
'getPageCanvas',
'getHitMap',
'handleSceneReady'
]);
}
async initialize() {
this.pageFormat = this.getModule('book-page-format');
this.localization = this.getModule('localization');
this.reportProgress(20, 'Preparing page texture canvases');
this.createPageCanvases();
this.drawEmptySpread();
this.addEventListener(document, 'webgl-book:scene-ready', this.handleSceneReady);
this.reportProgress(100, 'Book texture renderer ready');
return true;
}
createPageCanvases(textureWidth = 1280) {
this.metrics = this.pageFormat.getTextureMetrics(textureWidth);
['left', 'right'].forEach((side) => {
const canvas = document.createElement('canvas');
canvas.width = this.metrics.width;
canvas.height = this.metrics.height;
this.canvases[side] = canvas;
this.contexts[side] = canvas.getContext('2d');
});
}
drawEmptySpread() {
this.drawPageBase('left');
this.drawPageBase('right');
this.drawDebugText('right', 'Book canvas renderer ready');
this.publishSpread();
}
drawPageBase(side) {
const canvas = this.canvases[side];
const ctx = this.contexts[side];
if (!canvas || !ctx) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#fff7dc';
ctx.fillRect(0, 0, canvas.width, canvas.height);
const shade = ctx.createLinearGradient(0, 0, canvas.width, 0);
if (side === 'left') {
shade.addColorStop(0, 'rgba(255, 255, 255, 0.10)');
shade.addColorStop(0.78, 'rgba(255, 255, 255, 0)');
shade.addColorStop(1, 'rgba(82, 42, 14, 0.16)');
} else {
shade.addColorStop(0, 'rgba(82, 42, 14, 0.16)');
shade.addColorStop(0.22, 'rgba(255, 255, 255, 0)');
shade.addColorStop(1, 'rgba(255, 255, 255, 0.10)');
}
ctx.fillStyle = shade;
ctx.fillRect(0, 0, canvas.width, canvas.height);
this.hitMaps[side] = [];
}
drawDebugText(side, text) {
const ctx = this.contexts[side];
const metrics = this.metrics;
if (!ctx || !metrics) return;
ctx.save();
ctx.fillStyle = 'rgba(31, 19, 10, 0.82)';
ctx.font = `${Math.round(metrics.typography.bodyFontSizePt * 1.55)}px ${metrics.typography.fontFamily}`;
ctx.textBaseline = 'alphabetic';
ctx.fillText(String(text || ''), metrics.content.x, metrics.content.y + 44);
ctx.restore();
}
publishSpread() {
document.dispatchEvent(new CustomEvent('webgl-book:page-canvases', {
detail: {
left: this.canvases.left,
right: this.canvases.right,
metrics: this.metrics,
hitMaps: this.hitMaps
}
}));
}
getPageCanvas(side) {
return this.canvases[side] || null;
}
getHitMap(side) {
return this.hitMaps[side] || [];
}
handleSceneReady() {
this.publishSpread();
}
}
const bookTextureRenderer = new BookTextureRendererModule();
export { bookTextureRenderer as BookTextureRenderer };
if (window.moduleRegistry) {
window.moduleRegistry.register(bookTextureRenderer);
}
window.BookTextureRenderer = bookTextureRenderer;
+3 -1
View File
@@ -24,7 +24,7 @@ const ModuleState = {
ERROR: 'ERROR' ERROR: 'ERROR'
}; };
const MODULE_CACHE_BUSTER = '20260606-webgl-direct-page-crop-coords'; const MODULE_CACHE_BUSTER = '20260606-webgl-texture-renderer-foundation';
window.MODULE_CACHE_BUSTER = MODULE_CACHE_BUSTER; window.MODULE_CACHE_BUSTER = MODULE_CACHE_BUSTER;
/** /**
@@ -113,6 +113,8 @@ const ModuleLoader = (function() {
{ id: 'paragraph-layout', script: '/js/paragraph-layout-module.js', weight: 17 }, { id: 'paragraph-layout', script: '/js/paragraph-layout-module.js', weight: 17 },
{ id: 'sentence-queue', script: '/js/sentence-queue-module.js', weight: 12 }, { id: 'sentence-queue', script: '/js/sentence-queue-module.js', weight: 12 },
{ id: 'layout-renderer', script: '/js/layout-renderer-module.js', weight: 13 }, // Add Layout Renderer module { id: 'layout-renderer', script: '/js/layout-renderer-module.js', weight: 13 }, // Add Layout Renderer module
{ id: 'book-page-format', script: '/js/book-page-format-module.js', weight: 4 },
{ id: 'book-texture-renderer', script: '/js/book-texture-renderer-module.js', weight: 6 },
{ id: 'webgl-book-scene', script: '/js/webgl-book-scene-module.js', weight: 13 }, { id: 'webgl-book-scene', script: '/js/webgl-book-scene-module.js', weight: 13 },
{ id: 'animation-queue', script: '/js/animation-queue-module.js', weight: 12 }, { id: 'animation-queue', script: '/js/animation-queue-module.js', weight: 12 },
{ id: 'playback-coordinator', script: '/js/playback-coordinator-module.js', weight: 8 }, // Synchronizes animation + TTS { id: 'playback-coordinator', script: '/js/playback-coordinator-module.js', weight: 8 }, // Synchronizes animation + TTS
+1 -1
View File
@@ -6,7 +6,7 @@ export const PROCEDURAL_BOOK = {
PAGE_COUNT_STEP: 10, PAGE_COUNT_STEP: 10,
PAGE_LINE_SEGMENTS: 48, PAGE_LINE_SEGMENTS: 48,
PAGE_DEPTH: 2.24, PAGE_DEPTH: 2.24,
PAGE_WIDTH: 2.24 * 0.806, PAGE_WIDTH: 2.24 * 2 / 3,
COVER_DEPTH: 2.30, COVER_DEPTH: 2.30,
OPEN_SEAM_GAP: 0.003, OPEN_SEAM_GAP: 0.003,
PROFILE: { PROFILE: {
+23 -93
View File
@@ -4,7 +4,7 @@ import { RenderPass } from 'https://esm.sh/three@0.165.0/examples/jsm/postproces
import { SSAOPass } from 'https://esm.sh/three@0.165.0/examples/jsm/postprocessing/SSAOPass.js'; import { SSAOPass } from 'https://esm.sh/three@0.165.0/examples/jsm/postprocessing/SSAOPass.js';
import { SMAAPass } from 'https://esm.sh/three@0.165.0/examples/jsm/postprocessing/SMAAPass.js'; import { SMAAPass } from 'https://esm.sh/three@0.165.0/examples/jsm/postprocessing/SMAAPass.js';
import { OutputPass } from 'https://esm.sh/three@0.165.0/examples/jsm/postprocessing/OutputPass.js'; import { OutputPass } from 'https://esm.sh/three@0.165.0/examples/jsm/postprocessing/OutputPass.js';
import { PROCEDURAL_BOOK, createProceduralBookModel, snapProceduralPageCount } from './procedural-book-model.js?v=20260606-webgl-no-menu-offscreen-dom'; import { PROCEDURAL_BOOK, createProceduralBookModel, snapProceduralPageCount } from './procedural-book-model.js?v=20260606-book-page-format-restore';
const canvas = document.getElementById('scene'); const canvas = document.getElementById('scene');
canvas.style.cursor = 'grab'; canvas.style.cursor = 'grab';
@@ -25,9 +25,6 @@ const appInitialState = window.WebGLBookInitialState || {};
const tableDebugName = urlParams.get('tableDebug') || 'none'; const tableDebugName = urlParams.get('tableDebug') || 'none';
const tableDebugMode = tableDebugModes[tableDebugName] ?? tableDebugModes.none; const tableDebugMode = tableDebugModes[tableDebugName] ?? tableDebugModes.none;
const isAppIntegrationMode = appInitialState.appMode === true; const isAppIntegrationMode = appInitialState.appMode === true;
const html2CanvasPromise = isAppIntegrationMode
? import('https://esm.sh/html2canvas@1.4.1')
: null;
const labStatus = document.getElementById('lab_status'); const labStatus = document.getElementById('lab_status');
if (labStatus && tableDebugMode !== tableDebugModes.none) { if (labStatus && tableDebugMode !== tableDebugModes.none) {
labStatus.textContent = tableDebugName === 'ao' ? 'scene debug: SSAO' : `table debug: ${tableDebugName}`; labStatus.textContent = tableDebugName === 'ao' ? 'scene debug: SSAO' : `table debug: ${tableDebugName}`;
@@ -44,13 +41,9 @@ const generatedTextureCanvases = {};
const maxTextureAnisotropy = renderer.capabilities.getMaxAnisotropy(); const maxTextureAnisotropy = renderer.capabilities.getMaxAnisotropy();
const reflectionPixelRatio = Math.min(window.devicePixelRatio || 1, 2); const reflectionPixelRatio = Math.min(window.devicePixelRatio || 1, 2);
const pageTextureWidth = isAppIntegrationMode ? 1280 : 3200; const pageTextureWidth = isAppIntegrationMode ? 1280 : 3200;
const appPageTextureInset = 0;
const reflectionTargetSize = new THREE.Vector2(); const reflectionTargetSize = new THREE.Vector2();
const pageRaycaster = new THREE.Raycaster(); const pageRaycaster = new THREE.Raycaster();
const pointerNdc = new THREE.Vector2(); const pointerNdc = new THREE.Vector2();
let pageTextureRenderSerial = 0;
let pageTextureRenderInProgress = false;
let pageTextureRenderPending = false;
let sceneComposerTarget = null; let sceneComposerTarget = null;
let composer = null; let composer = null;
let sceneRenderPass = null; let sceneRenderPass = null;
@@ -402,14 +395,13 @@ window.BookLabDebug = {
return bookPageCount; return bookPageCount;
}, },
redrawPageTextures() { redrawPageTextures() {
redrawPageTexturesFromDom(); window.BookTextureRenderer?.publishSpread?.();
return true; return true;
}, },
getTextureInfo() { getTextureInfo() {
return { return {
pageTextureWidth, pageTextureWidth,
pageTextureHeight: leftCanvas.height, pageTextureHeight: leftCanvas.height,
appPageTextureInset,
debug: getPageTextureDebugState() debug: getPageTextureDebugState()
}; };
}, },
@@ -424,10 +416,11 @@ window.BookLabDebug = {
}; };
window.addEventListener('resize', resize); window.addEventListener('resize', resize);
document.addEventListener('webgl-book:redraw-pages', redrawPageTexturesFromDom); document.addEventListener('webgl-book:page-canvases', handlePageCanvases);
installBookControls(); installBookControls();
installCameraControls(); installCameraControls();
resize(); resize();
document.dispatchEvent(new CustomEvent('webgl-book:scene-ready'));
animate(); animate();
function buildTable() { function buildTable() {
@@ -1444,35 +1437,24 @@ function syncBookControls() {
if (fastForwardButton) fastForwardButton.disabled = busy || !canPageFlip(1); if (fastForwardButton) fastForwardButton.disabled = busy || !canPageFlip(1);
} }
function redrawPageTexturesFromDom() { function handlePageCanvases(event) {
if (pageTextureRenderInProgress) { const detail = event.detail || {};
pageTextureRenderPending = true; if (detail.left) {
return; drawCanvasPageTexture(leftCanvas, detail.left, 'left');
}
const leftSource = document.getElementById('page_left');
const rightSource = document.getElementById('page_right');
if (!leftSource && !rightSource) return;
pageTextureRenderInProgress = true;
const serial = ++pageTextureRenderSerial;
(async () => {
try {
if (leftSource && await drawDomPageTexture(leftCanvas, leftSource, 'left')) {
leftTexture.needsUpdate = true; leftTexture.needsUpdate = true;
} }
if (rightSource && await drawDomPageTexture(rightCanvas, rightSource, 'right')) { if (detail.right) {
drawCanvasPageTexture(rightCanvas, detail.right, 'right');
rightTexture.needsUpdate = true; rightTexture.needsUpdate = true;
} }
} finally { document.documentElement.dataset.webglPageTextureMetrics = JSON.stringify({
pageTextureRenderInProgress = false; width: leftCanvas.width,
if (pageTextureRenderPending && serial === pageTextureRenderSerial) { height: leftCanvas.height,
pageTextureRenderPending = false; source: 'book-texture-renderer'
redrawPageTexturesFromDom(); });
}
}
})();
} }
async function drawDomPageTexture(canvas, source, side) { function drawCanvasPageTexture(canvas, sourceCanvas, side) {
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
ctx.fillStyle = '#fffaf0'; ctx.fillStyle = '#fffaf0';
ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.fillRect(0, 0, canvas.width, canvas.height);
@@ -1484,9 +1466,9 @@ async function drawDomPageTexture(canvas, source, side) {
ctx.fillStyle = shade; ctx.fillStyle = shade;
ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.fillRect(0, 0, canvas.width, canvas.height);
const painted = await paintRasterizedDomPage(ctx, canvas, source); ctx.drawImage(sourceCanvas, 0, 0, canvas.width, canvas.height);
updatePageTextureDebugState(side, canvas, source, painted); updatePageTextureDebugState(side, canvas, sourceCanvas, true);
return painted; return true;
} }
function getPageTextureDebugState() { function getPageTextureDebugState() {
@@ -1505,8 +1487,8 @@ function updatePageTextureDebugState(side, canvas, source, painted) {
painted, painted,
width: canvas.width, width: canvas.width,
height: canvas.height, height: canvas.height,
sourceId: source.id || '', sourceId: source?.id || 'book-texture-renderer',
sourceTextLength: source.textContent?.trim().length || 0, sourceTextLength: 0,
darkPixels: countPageTextureDarkPixels(canvas) darkPixels: countPageTextureDarkPixels(canvas)
}; };
document.documentElement.dataset.webglPageTextures = JSON.stringify(state); document.documentElement.dataset.webglPageTextures = JSON.stringify(state);
@@ -1530,56 +1512,6 @@ function countPageTextureDarkPixels(canvas) {
return darkPixels; return darkPixels;
} }
async function paintRasterizedDomPage(ctx, canvas, source) {
const pageRect = source.getBoundingClientRect();
if (pageRect.width <= 0 || pageRect.height <= 0) return false;
const captured = await captureDomPageWithHtml2Canvas(source, pageRect, canvas);
if (captured) {
drawCapturedPageCanvas(ctx, canvas, captured);
return true;
}
return false;
}
async function captureDomPageWithHtml2Canvas(source, pageRect, targetCanvas) {
if (!html2CanvasPromise) return null;
try {
const module = await html2CanvasPromise;
const html2canvas = module.default || module;
return await html2canvas(source, {
backgroundColor: null,
logging: false,
useCORS: true,
allowTaint: false,
foreignObjectRendering: true,
x: pageRect.left,
y: pageRect.top,
width: pageRect.width,
height: pageRect.height,
scrollX: 0,
scrollY: 0,
windowWidth: Math.ceil(Math.max(window.innerWidth, pageRect.right)),
windowHeight: Math.ceil(Math.max(window.innerHeight, pageRect.bottom)),
scale: Math.max(1, targetCanvas.width / pageRect.width)
});
} catch (error) {
document.documentElement.dataset.webglLastCaptureError = error?.message || String(error);
return null;
}
}
function drawCapturedPageCanvas(ctx, canvas, captured) {
const insetX = canvas.width * appPageTextureInset;
const insetY = canvas.height * appPageTextureInset * 0.35;
ctx.drawImage(
captured,
insetX,
insetY,
canvas.width - insetX * 2,
canvas.height - insetY * 2
);
}
function projectPointerToPage(clientX, clientY) { function projectPointerToPage(clientX, clientY) {
const rect = canvas.getBoundingClientRect(); const rect = canvas.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) return null; if (rect.width <= 0 || rect.height <= 0) return null;
@@ -1592,10 +1524,8 @@ function projectPointerToPage(clientX, clientY) {
for (const hit of intersections) { for (const hit of intersections) {
const pageSide = textureHitPageSide(hit); const pageSide = textureHitPageSide(hit);
if (!pageSide || !hit.uv) continue; if (!pageSide || !hit.uv) continue;
const insetX = appPageTextureInset; const mappedX = THREE.MathUtils.clamp(hit.uv.x, 0, 1);
const insetY = appPageTextureInset * 0.35; const mappedY = 1 - THREE.MathUtils.clamp(hit.uv.y, 0, 1);
const mappedX = THREE.MathUtils.clamp((hit.uv.x - insetX) / Math.max(0.001, 1 - insetX * 2), 0, 1);
const mappedY = 1 - THREE.MathUtils.clamp((hit.uv.y - insetY) / Math.max(0.001, 1 - insetY * 2), 0, 1);
return { return {
pageId: pageSide === 'left' ? 'page_left' : 'page_right', pageId: pageSide === 'left' ? 'page_left' : 'page_right',
x: mappedX, x: mappedX,
-2
View File
@@ -439,7 +439,6 @@ class WebGLBookSceneModule extends BaseModule {
triggerTextureRefresh() { triggerTextureRefresh() {
clearTimeout(this.textureRefreshTimer); clearTimeout(this.textureRefreshTimer);
this.textureRefreshTimer = setTimeout(() => { this.textureRefreshTimer = setTimeout(() => {
document.dispatchEvent(new CustomEvent('webgl-book:redraw-pages'));
window.BookLabDebug?.redrawPageTextures?.(); window.BookLabDebug?.redrawPageTextures?.();
}, 60); }, 60);
} }
@@ -463,7 +462,6 @@ class WebGLBookSceneModule extends BaseModule {
} }
if (now - this.lastAnimatedTextureRefresh > 100) { if (now - this.lastAnimatedTextureRefresh > 100) {
this.lastAnimatedTextureRefresh = now; this.lastAnimatedTextureRefresh = now;
document.dispatchEvent(new CustomEvent('webgl-book:redraw-pages'));
window.BookLabDebug?.redrawPageTextures?.(); window.BookLabDebug?.redrawPageTextures?.();
} }
this.textureRefreshAnimationId = window.requestAnimationFrame(tick); this.textureRefreshAnimationId = window.requestAnimationFrame(tick);