Start texture-space book renderer
This commit is contained in:
@@ -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;
|
||||
@@ -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
@@ -24,7 +24,7 @@ const ModuleState = {
|
||||
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;
|
||||
|
||||
/**
|
||||
@@ -113,6 +113,8 @@ const ModuleLoader = (function() {
|
||||
{ id: 'paragraph-layout', script: '/js/paragraph-layout-module.js', weight: 17 },
|
||||
{ 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: '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: 'animation-queue', script: '/js/animation-queue-module.js', weight: 12 },
|
||||
{ id: 'playback-coordinator', script: '/js/playback-coordinator-module.js', weight: 8 }, // Synchronizes animation + TTS
|
||||
|
||||
@@ -6,7 +6,7 @@ export const PROCEDURAL_BOOK = {
|
||||
PAGE_COUNT_STEP: 10,
|
||||
PAGE_LINE_SEGMENTS: 48,
|
||||
PAGE_DEPTH: 2.24,
|
||||
PAGE_WIDTH: 2.24 * 0.806,
|
||||
PAGE_WIDTH: 2.24 * 2 / 3,
|
||||
COVER_DEPTH: 2.30,
|
||||
OPEN_SEAM_GAP: 0.003,
|
||||
PROFILE: {
|
||||
|
||||
+23
-93
@@ -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 { 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 { 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');
|
||||
canvas.style.cursor = 'grab';
|
||||
@@ -25,9 +25,6 @@ const appInitialState = window.WebGLBookInitialState || {};
|
||||
const tableDebugName = urlParams.get('tableDebug') || 'none';
|
||||
const tableDebugMode = tableDebugModes[tableDebugName] ?? tableDebugModes.none;
|
||||
const isAppIntegrationMode = appInitialState.appMode === true;
|
||||
const html2CanvasPromise = isAppIntegrationMode
|
||||
? import('https://esm.sh/html2canvas@1.4.1')
|
||||
: null;
|
||||
const labStatus = document.getElementById('lab_status');
|
||||
if (labStatus && tableDebugMode !== tableDebugModes.none) {
|
||||
labStatus.textContent = tableDebugName === 'ao' ? 'scene debug: SSAO' : `table debug: ${tableDebugName}`;
|
||||
@@ -44,13 +41,9 @@ const generatedTextureCanvases = {};
|
||||
const maxTextureAnisotropy = renderer.capabilities.getMaxAnisotropy();
|
||||
const reflectionPixelRatio = Math.min(window.devicePixelRatio || 1, 2);
|
||||
const pageTextureWidth = isAppIntegrationMode ? 1280 : 3200;
|
||||
const appPageTextureInset = 0;
|
||||
const reflectionTargetSize = new THREE.Vector2();
|
||||
const pageRaycaster = new THREE.Raycaster();
|
||||
const pointerNdc = new THREE.Vector2();
|
||||
let pageTextureRenderSerial = 0;
|
||||
let pageTextureRenderInProgress = false;
|
||||
let pageTextureRenderPending = false;
|
||||
let sceneComposerTarget = null;
|
||||
let composer = null;
|
||||
let sceneRenderPass = null;
|
||||
@@ -402,14 +395,13 @@ window.BookLabDebug = {
|
||||
return bookPageCount;
|
||||
},
|
||||
redrawPageTextures() {
|
||||
redrawPageTexturesFromDom();
|
||||
window.BookTextureRenderer?.publishSpread?.();
|
||||
return true;
|
||||
},
|
||||
getTextureInfo() {
|
||||
return {
|
||||
pageTextureWidth,
|
||||
pageTextureHeight: leftCanvas.height,
|
||||
appPageTextureInset,
|
||||
debug: getPageTextureDebugState()
|
||||
};
|
||||
},
|
||||
@@ -424,10 +416,11 @@ window.BookLabDebug = {
|
||||
};
|
||||
|
||||
window.addEventListener('resize', resize);
|
||||
document.addEventListener('webgl-book:redraw-pages', redrawPageTexturesFromDom);
|
||||
document.addEventListener('webgl-book:page-canvases', handlePageCanvases);
|
||||
installBookControls();
|
||||
installCameraControls();
|
||||
resize();
|
||||
document.dispatchEvent(new CustomEvent('webgl-book:scene-ready'));
|
||||
animate();
|
||||
|
||||
function buildTable() {
|
||||
@@ -1444,35 +1437,24 @@ function syncBookControls() {
|
||||
if (fastForwardButton) fastForwardButton.disabled = busy || !canPageFlip(1);
|
||||
}
|
||||
|
||||
function redrawPageTexturesFromDom() {
|
||||
if (pageTextureRenderInProgress) {
|
||||
pageTextureRenderPending = true;
|
||||
return;
|
||||
}
|
||||
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')) {
|
||||
function handlePageCanvases(event) {
|
||||
const detail = event.detail || {};
|
||||
if (detail.left) {
|
||||
drawCanvasPageTexture(leftCanvas, detail.left, 'left');
|
||||
leftTexture.needsUpdate = true;
|
||||
}
|
||||
if (rightSource && await drawDomPageTexture(rightCanvas, rightSource, 'right')) {
|
||||
if (detail.right) {
|
||||
drawCanvasPageTexture(rightCanvas, detail.right, 'right');
|
||||
rightTexture.needsUpdate = true;
|
||||
}
|
||||
} finally {
|
||||
pageTextureRenderInProgress = false;
|
||||
if (pageTextureRenderPending && serial === pageTextureRenderSerial) {
|
||||
pageTextureRenderPending = false;
|
||||
redrawPageTexturesFromDom();
|
||||
}
|
||||
}
|
||||
})();
|
||||
document.documentElement.dataset.webglPageTextureMetrics = JSON.stringify({
|
||||
width: leftCanvas.width,
|
||||
height: leftCanvas.height,
|
||||
source: 'book-texture-renderer'
|
||||
});
|
||||
}
|
||||
|
||||
async function drawDomPageTexture(canvas, source, side) {
|
||||
function drawCanvasPageTexture(canvas, sourceCanvas, side) {
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.fillStyle = '#fffaf0';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
@@ -1484,9 +1466,9 @@ async function drawDomPageTexture(canvas, source, side) {
|
||||
ctx.fillStyle = shade;
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
const painted = await paintRasterizedDomPage(ctx, canvas, source);
|
||||
updatePageTextureDebugState(side, canvas, source, painted);
|
||||
return painted;
|
||||
ctx.drawImage(sourceCanvas, 0, 0, canvas.width, canvas.height);
|
||||
updatePageTextureDebugState(side, canvas, sourceCanvas, true);
|
||||
return true;
|
||||
}
|
||||
|
||||
function getPageTextureDebugState() {
|
||||
@@ -1505,8 +1487,8 @@ function updatePageTextureDebugState(side, canvas, source, painted) {
|
||||
painted,
|
||||
width: canvas.width,
|
||||
height: canvas.height,
|
||||
sourceId: source.id || '',
|
||||
sourceTextLength: source.textContent?.trim().length || 0,
|
||||
sourceId: source?.id || 'book-texture-renderer',
|
||||
sourceTextLength: 0,
|
||||
darkPixels: countPageTextureDarkPixels(canvas)
|
||||
};
|
||||
document.documentElement.dataset.webglPageTextures = JSON.stringify(state);
|
||||
@@ -1530,56 +1512,6 @@ function countPageTextureDarkPixels(canvas) {
|
||||
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) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
if (rect.width <= 0 || rect.height <= 0) return null;
|
||||
@@ -1592,10 +1524,8 @@ function projectPointerToPage(clientX, clientY) {
|
||||
for (const hit of intersections) {
|
||||
const pageSide = textureHitPageSide(hit);
|
||||
if (!pageSide || !hit.uv) continue;
|
||||
const insetX = appPageTextureInset;
|
||||
const insetY = appPageTextureInset * 0.35;
|
||||
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);
|
||||
const mappedX = THREE.MathUtils.clamp(hit.uv.x, 0, 1);
|
||||
const mappedY = 1 - THREE.MathUtils.clamp(hit.uv.y, 0, 1);
|
||||
return {
|
||||
pageId: pageSide === 'left' ? 'page_left' : 'page_right',
|
||||
x: mappedX,
|
||||
|
||||
@@ -439,7 +439,6 @@ class WebGLBookSceneModule extends BaseModule {
|
||||
triggerTextureRefresh() {
|
||||
clearTimeout(this.textureRefreshTimer);
|
||||
this.textureRefreshTimer = setTimeout(() => {
|
||||
document.dispatchEvent(new CustomEvent('webgl-book:redraw-pages'));
|
||||
window.BookLabDebug?.redrawPageTextures?.();
|
||||
}, 60);
|
||||
}
|
||||
@@ -463,7 +462,6 @@ class WebGLBookSceneModule extends BaseModule {
|
||||
}
|
||||
if (now - this.lastAnimatedTextureRefresh > 100) {
|
||||
this.lastAnimatedTextureRefresh = now;
|
||||
document.dispatchEvent(new CustomEvent('webgl-book:redraw-pages'));
|
||||
window.BookLabDebug?.redrawPageTextures?.();
|
||||
}
|
||||
this.textureRefreshAnimationId = window.requestAnimationFrame(tick);
|
||||
|
||||
Reference in New Issue
Block a user