Checkpoint WebGL book renderer work

This commit is contained in:
2026-06-06 14:35:37 +02:00
parent 83ca095d54
commit b734d83227
9 changed files with 846 additions and 666 deletions
+261 -86
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 { 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';
import { PROCEDURAL_BOOK, createProceduralBookModel, snapProceduralPageCount } from './procedural-book-model.js?v=20260606-webgl-no-menu-offscreen-dom';
const canvas = document.getElementById('scene');
canvas.style.cursor = 'grab';
@@ -21,8 +21,13 @@ const tableDebugModes = {
mirror: 10
};
const urlParams = new URLSearchParams(window.location.search);
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}`;
@@ -38,8 +43,14 @@ renderer.shadowMap.type = THREE.VSMShadowMap;
const generatedTextureCanvases = {};
const maxTextureAnisotropy = renderer.capabilities.getMaxAnisotropy();
const reflectionPixelRatio = Math.min(window.devicePixelRatio || 1, 2);
const pageTextureWidth = 3200;
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;
@@ -47,6 +58,7 @@ let sceneAoPass = null;
let sceneSmaaPass = null;
let sceneOutputPass = null;
const aoExcludedObjects = new Set();
let renderedFrameCount = 0;
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x080604);
@@ -59,11 +71,13 @@ let tableDustTexture = null;
let tableGreaseTexture = null;
const tableTopY = -0.02;
const bookTableContactClearance = 0.002;
const tableReflectionTarget = new THREE.WebGLRenderTarget(4096, 2304, {
const tableReflectionBaseWidth = isAppIntegrationMode ? 1280 : 4096;
const tableReflectionBaseHeight = isAppIntegrationMode ? 720 : 2304;
const tableReflectionTarget = new THREE.WebGLRenderTarget(tableReflectionBaseWidth, tableReflectionBaseHeight, {
colorSpace: THREE.SRGBColorSpace,
depthBuffer: true,
stencilBuffer: false,
samples: renderer.capabilities.isWebGL2 ? 8 : 0
samples: renderer.capabilities.isWebGL2 ? (isAppIntegrationMode ? 2 : 8) : 0
});
tableReflectionTarget.texture.colorSpace = THREE.SRGBColorSpace;
tableReflectionTarget.texture.minFilter = THREE.LinearFilter;
@@ -82,7 +96,7 @@ const reflectionUp = new THREE.Vector3();
const candleShadowSources = [];
const candleWorldPosition = new THREE.Vector3();
const flameWorldPosition = new THREE.Vector3();
const bookShadowMapSize = 1536;
const bookShadowMapSize = isAppIntegrationMode ? 512 : 1536;
const bookShadowTargets = Array.from({ length: 3 }, () => {
const target = new THREE.WebGLRenderTarget(bookShadowMapSize, bookShadowMapSize, {
colorSpace: THREE.NoColorSpace,
@@ -119,6 +133,7 @@ const cameraRig = {
minRadius: 2.4,
maxRadius: 9.0,
dragging: false,
navigationActive: false,
pointerX: 0,
pointerY: 0,
keys: new Set()
@@ -135,9 +150,9 @@ configureScenePostprocessing();
const clock = new THREE.Clock();
const book = new THREE.Group();
scene.add(book);
const initialReadingProgress = THREE.MathUtils.clamp(Number.parseFloat(urlParams.get('progress') ?? '0.28'), 0, 1);
const initialReadingProgress = THREE.MathUtils.clamp(Number.parseFloat(urlParams.get('progress') ?? appInitialState.progress ?? '0.28'), 0, 1);
let readingProgress = Number.isFinite(initialReadingProgress) ? initialReadingProgress : 0.28;
let bookPageCount = snapProceduralPageCount(urlParams.get('pages') ?? '240');
let bookPageCount = snapProceduralPageCount(urlParams.get('pages') ?? appInitialState.pageCount ?? '240');
let currentProceduralBookModel = null;
const progressInput = document.getElementById('progress_control');
const progressValue = document.getElementById('progress_value');
@@ -382,12 +397,34 @@ window.BookLabDebug = {
setReadingProgress(value);
return readingProgress;
},
setBookPageCount(value) {
setBookPageCount(value);
return bookPageCount;
},
redrawPageTextures() {
redrawPageTexturesFromDom();
return true;
},
getTextureInfo() {
return {
pageTextureWidth,
pageTextureHeight: leftCanvas.height,
appPageTextureInset,
debug: getPageTextureDebugState()
};
},
projectPointerToPage(clientX, clientY) {
return projectPointerToPage(clientX, clientY);
},
exportTexture(name) {
if (name === 'left' || name === 'leftPage') return leftCanvas.toDataURL('image/png');
if (name === 'right' || name === 'rightPage') return rightCanvas.toDataURL('image/png');
return generatedTextureCanvases[name]?.toDataURL('image/png') || null;
}
};
window.addEventListener('resize', resize);
document.addEventListener('webgl-book:redraw-pages', redrawPageTexturesFromDom);
installBookControls();
installCameraControls();
resize();
@@ -1362,6 +1399,7 @@ function setReadingProgress(value) {
readingProgress = nextProgress;
buildBook();
syncBookControls();
window.WebGLBookPreferenceBridge?.updateProgress?.(readingProgress);
}
function setBookPageCount(value) {
@@ -1370,6 +1408,7 @@ function setBookPageCount(value) {
bookPageCount = nextPageCount;
buildBook();
syncBookControls();
window.WebGLBookPreferenceBridge?.updatePageCount?.(bookPageCount);
}
function stepReadingProgress(pageDelta) {
@@ -1405,6 +1444,179 @@ 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')) {
leftTexture.needsUpdate = true;
}
if (rightSource && await drawDomPageTexture(rightCanvas, rightSource, 'right')) {
rightTexture.needsUpdate = true;
}
} finally {
pageTextureRenderInProgress = false;
if (pageTextureRenderPending && serial === pageTextureRenderSerial) {
pageTextureRenderPending = false;
redrawPageTexturesFromDom();
}
}
})();
}
async function drawDomPageTexture(canvas, source, side) {
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#fffaf0';
ctx.fillRect(0, 0, canvas.width, canvas.height);
const shade = ctx.createLinearGradient(0, 0, canvas.width, 0);
shade.addColorStop(0, 'rgba(93, 55, 24, 0.10)');
shade.addColorStop(side === 'left' ? 0.85 : 0.15, 'rgba(255, 255, 255, 0)');
shade.addColorStop(1, 'rgba(85, 49, 21, 0.08)');
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;
}
function getPageTextureDebugState() {
const rawState = document.documentElement.dataset.webglPageTextures;
if (!rawState) return {};
try {
return JSON.parse(rawState);
} catch (error) {
return {};
}
}
function updatePageTextureDebugState(side, canvas, source, painted) {
const state = getPageTextureDebugState();
state[side] = {
painted,
width: canvas.width,
height: canvas.height,
sourceId: source.id || '',
sourceTextLength: source.textContent?.trim().length || 0,
darkPixels: countPageTextureDarkPixels(canvas)
};
document.documentElement.dataset.webglPageTextures = JSON.stringify(state);
}
function countPageTextureDarkPixels(canvas) {
const sampleCanvas = document.createElement('canvas');
const sampleSize = 64;
sampleCanvas.width = sampleSize;
sampleCanvas.height = sampleSize;
const sampleContext = sampleCanvas.getContext('2d');
sampleContext.drawImage(canvas, 0, 0, sampleSize, sampleSize);
const pixels = sampleContext.getImageData(0, 0, sampleSize, sampleSize).data;
let darkPixels = 0;
for (let index = 0; index < pixels.length; index += 4) {
const alpha = pixels[index + 3];
if (alpha < 8) continue;
const luminance = pixels[index] * 0.2126 + pixels[index + 1] * 0.7152 + pixels[index + 2] * 0.0722;
if (luminance < 96) darkPixels += 1;
}
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;
pointerNdc.set(
((clientX - rect.left) / rect.width) * 2 - 1,
-(((clientY - rect.top) / rect.height) * 2 - 1)
);
pageRaycaster.setFromCamera(pointerNdc, camera);
const intersections = pageRaycaster.intersectObjects(book.children, true);
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);
return {
pageId: pageSide === 'left' ? 'page_left' : 'page_right',
x: mappedX,
y: mappedY,
uv: { x: hit.uv.x, y: hit.uv.y }
};
}
return null;
}
function textureHitPageSide(hit) {
const material = Array.isArray(hit.object.material)
? hit.object.material[hit.face?.materialIndex ?? 0]
: hit.object.material;
if (material === materials.leftPage) return 'left';
if (material === materials.rightPage) return 'right';
if (material?.map === leftTexture) return 'left';
if (material?.map === rightTexture) return 'right';
return null;
}
function startPageFlip(direction) {
if (activeFlips.length || !currentProceduralBookModel || !canPageFlip(direction)) return false;
const flip = createPageFlip(direction, performance.now(), normalFlipDuration);
@@ -1956,41 +2168,9 @@ function createPageCanvas(side) {
shade.addColorStop(1, 'rgba(85, 49, 21, 0.08)');
ctx.fillStyle = shade;
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = inkColor;
ctx.textBaseline = 'top';
const layout = hardcoverPageLayout(canvas, side);
if (side === 'left') {
drawTitlePage(ctx, layout);
} else {
drawNovelPage(ctx, layout, 'Click on new game or load to start the game');
}
return canvas;
}
function hardcoverPageLayout(canvas, side) {
const inner = canvas.width * 0.125;
const outer = canvas.width * 0.075;
const top = canvas.height * 0.085;
const bottom = canvas.height * 0.115;
const margins = {
left: side === 'right' ? inner : outer,
right: side === 'right' ? outer : inner,
top,
bottom
};
const width = canvas.width - margins.left - margins.right;
const height = canvas.height - margins.top - margins.bottom;
return {
margins,
x: margins.left,
y: margins.top,
width,
height,
em: width / 24
};
}
function createLeatherTextures() {
const size = 1024;
const colorCanvas = document.createElement('canvas');
@@ -2424,49 +2604,6 @@ function tintAmbientFromCanvas(canvas) {
candleBounceLight.intensity = 0.28;
}
function drawTitlePage(ctx, layout) {
const titleX = layout.x;
const titleWidth = layout.width;
drawCentered(ctx, 'Georg Tomitsch', layout.y + layout.height * 0.18, layout.em * 0.62, titleX, titleWidth);
drawCentered(ctx, 'Eibenreith', layout.y + layout.height * 0.235, layout.em * 1.72, titleX, titleWidth);
drawCentered(ctx, 'Ein Kaiserpunk Abenteuer', layout.y + layout.height * 0.315, layout.em * 0.76, titleX, titleWidth);
drawCentered(ctx, 'speech | autoplay | speed | new game | save | load | options', layout.y + layout.height * 0.47, layout.em * 0.42, titleX, titleWidth);
drawCentered(ctx, 'click on page or press spacebar to fast forward text animation', layout.y + layout.height * 0.56, layout.em * 0.42, titleX, titleWidth);
}
function drawNovelPage(ctx, layout, text) {
const projectedX = Math.max(layout.margins.left * 0.25, layout.x - layout.margins.left * 0.75);
const projectedWidth = layout.width * 0.7;
drawParagraph(ctx, text, projectedX, layout.y + layout.height * 0.1, projectedWidth, layout.em * 0.72, 1.36, 0);
}
function drawCentered(ctx, text, y, size, x = 0, width = ctx.canvas.width) {
ctx.font = `${Math.round(size)}px Georgia, "Times New Roman", serif`;
ctx.textAlign = 'center';
ctx.fillText(text, x + width * 0.5, y);
}
function drawParagraph(ctx, text, x, y, width, size, lineHeight, firstLineIndent = 0) {
const fontSize = Math.round(size);
ctx.font = `${fontSize}px Georgia, "Times New Roman", serif`;
ctx.textAlign = 'left';
const words = text.split(/\s+/);
let line = '';
let indent = firstLineIndent;
words.forEach((word) => {
const test = line ? `${line} ${word}` : word;
if (ctx.measureText(test).width > width - indent && line) {
ctx.fillText(line, x + indent, y);
line = word;
y += fontSize * lineHeight;
indent = 0;
} else {
line = test;
}
});
if (line) ctx.fillText(line, x + indent, y);
}
function resize() {
const width = Math.max(1, window.innerWidth);
const height = Math.max(1, window.innerHeight);
@@ -2481,8 +2618,8 @@ function resize() {
4096 / width,
2304 / height
));
const reflectionWidth = Math.floor(width * reflectionScale);
const reflectionHeight = Math.floor(height * reflectionScale);
const reflectionWidth = Math.min(tableReflectionBaseWidth, Math.floor(width * reflectionScale));
const reflectionHeight = Math.min(tableReflectionBaseHeight, Math.floor(height * reflectionScale));
reflectionTargetSize.set(reflectionWidth, reflectionHeight);
tableReflectionTarget.setSize(
reflectionTargetSize.x,
@@ -2491,8 +2628,14 @@ function resize() {
}
function installCameraControls() {
canvas.addEventListener('contextmenu', (event) => {
event.preventDefault();
});
canvas.addEventListener('pointerdown', (event) => {
if (event.button !== 2) return;
cameraRig.dragging = true;
cameraRig.navigationActive = true;
canvas.style.cursor = 'grabbing';
cameraRig.pointerX = event.clientX;
cameraRig.pointerY = event.clientY;
@@ -2515,17 +2658,23 @@ function installCameraControls() {
});
canvas.addEventListener('pointerup', (event) => {
if (event.button !== 2) return;
cameraRig.dragging = false;
cameraRig.navigationActive = false;
cameraRig.keys.clear();
canvas.style.cursor = 'grab';
canvas.releasePointerCapture(event.pointerId);
});
canvas.addEventListener('pointercancel', () => {
cameraRig.dragging = false;
cameraRig.navigationActive = false;
cameraRig.keys.clear();
canvas.style.cursor = 'grab';
});
canvas.addEventListener('wheel', (event) => {
if (!cameraRig.navigationActive) return;
event.preventDefault();
const zoom = Math.exp(event.deltaY * 0.001);
cameraRig.radius = THREE.MathUtils.clamp(
@@ -2537,6 +2686,7 @@ function installCameraControls() {
}, { passive: false });
window.addEventListener('keydown', (event) => {
if (!cameraRig.navigationActive) return;
if (['KeyW', 'KeyA', 'KeyS', 'KeyD'].includes(event.code)) {
cameraRig.keys.add(event.code);
event.preventDefault();
@@ -2679,6 +2829,7 @@ function updateTableReflection() {
const previousXrEnabled = renderer.xr.enabled;
const previousShadowAutoUpdate = renderer.shadowMap.autoUpdate;
const previousToneMappingExposure = renderer.toneMappingExposure;
const pageTextureState = suppressPageContentMaps();
tableMesh.userData.wasVisibleForTableReflection = tableMesh.visible;
tableMesh.visible = false;
@@ -2692,10 +2843,29 @@ function updateTableReflection() {
renderer.toneMappingExposure = previousToneMappingExposure;
renderer.shadowMap.autoUpdate = previousShadowAutoUpdate;
renderer.xr.enabled = previousXrEnabled;
restorePageContentMaps(pageTextureState);
tableMesh.visible = tableMesh.userData.wasVisibleForTableReflection;
delete tableMesh.userData.wasVisibleForTableReflection;
}
function suppressPageContentMaps() {
if (!isAppIntegrationMode) return null;
return [materials.leftPage, materials.rightPage].map((material) => {
const previousMap = material.map;
material.map = null;
material.needsUpdate = true;
return { material, previousMap };
});
}
function restorePageContentMaps(state) {
if (!state) return;
state.forEach(({ material, previousMap }) => {
material.map = previousMap;
material.needsUpdate = true;
});
}
function renderMirrorDebugView() {
const hiddenObjects = [];
scene.traverse((object) => {
@@ -2746,8 +2916,13 @@ function animate() {
});
updateActiveFlips(performance.now());
updateCandleShadowUniforms();
updateBookShadowMaps();
updateTableReflection();
renderedFrameCount += 1;
if (!isAppIntegrationMode || renderedFrameCount % 6 === 1 || activeFlips.length > 0) {
updateBookShadowMaps();
}
if (!isAppIntegrationMode || renderedFrameCount % 4 === 1 || cameraRig.navigationActive || activeFlips.length > 0) {
updateTableReflection();
}
if (tableDebugMode === tableDebugModes.mirror) {
renderer.setRenderTarget(null);
renderer.clear();