Checkpoint WebGL book renderer work
This commit is contained in:
+32
-58
@@ -1940,7 +1940,8 @@ body.webgl-mode {
|
||||
background: #090705;
|
||||
}
|
||||
|
||||
#webgl_canvas {
|
||||
#webgl_canvas,
|
||||
#scene {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
@@ -1981,7 +1982,20 @@ body.webgl-mode {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.control_group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.control_group label,
|
||||
#lab_status {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
#top_menu_controls button,
|
||||
.transport_button,
|
||||
.modal-overview-row {
|
||||
font-family: 'EB Garamond', serif;
|
||||
font-size: 14px;
|
||||
@@ -1995,10 +2009,24 @@ body.webgl-mode {
|
||||
}
|
||||
|
||||
#top_menu_controls button:hover,
|
||||
.transport_button:hover,
|
||||
.modal-overview-row:hover {
|
||||
background: rgba(87, 55, 31, 0.78);
|
||||
}
|
||||
|
||||
.transport_button {
|
||||
width: 28px;
|
||||
height: 26px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.transport_button:disabled {
|
||||
cursor: var(--default-cursor, default);
|
||||
opacity: 0.38;
|
||||
}
|
||||
|
||||
#modal_overview {
|
||||
position: fixed;
|
||||
z-index: 45;
|
||||
@@ -2038,47 +2066,6 @@ body.webgl-mode {
|
||||
color: rgba(246, 231, 201, 0.62);
|
||||
}
|
||||
|
||||
body.webgl-mode #book {
|
||||
position: fixed;
|
||||
z-index: 20;
|
||||
inset: 38px 0 0;
|
||||
width: 100vw;
|
||||
height: calc(100vh - 38px);
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
background: transparent;
|
||||
pointer-events: none;
|
||||
transform: none;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
body.webgl-mode #page_left,
|
||||
body.webgl-mode #page_right {
|
||||
pointer-events: none;
|
||||
top: 10%;
|
||||
bottom: auto;
|
||||
height: 68vh;
|
||||
max-height: 760px;
|
||||
width: min(31vw, 500px);
|
||||
background: #f2dfb8;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
opacity: 1;
|
||||
mix-blend-mode: normal;
|
||||
}
|
||||
|
||||
body.webgl-mode #page_left {
|
||||
left: calc(50vw - min(33vw, 530px));
|
||||
transform: none;
|
||||
transform-origin: right center;
|
||||
}
|
||||
|
||||
body.webgl-mode #page_right {
|
||||
right: calc(50vw - min(33vw, 530px));
|
||||
transform: none;
|
||||
transform-origin: left center;
|
||||
}
|
||||
|
||||
body.webgl-mode #lighting {
|
||||
display: none;
|
||||
}
|
||||
@@ -2099,22 +2086,9 @@ body.webgl-mode #lighting {
|
||||
padding: 6px 7px;
|
||||
}
|
||||
|
||||
body.webgl-mode #book {
|
||||
inset: 46px 0 0;
|
||||
#lab_status,
|
||||
.control_group label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
body.webgl-mode #page_left,
|
||||
body.webgl-mode #page_right {
|
||||
width: 44vw;
|
||||
height: 66vh;
|
||||
top: 12%;
|
||||
}
|
||||
|
||||
body.webgl-mode #page_left {
|
||||
left: 6vw;
|
||||
}
|
||||
|
||||
body.webgl-mode #page_right {
|
||||
right: 6vw;
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@ const ModuleState = {
|
||||
ERROR: 'ERROR'
|
||||
};
|
||||
|
||||
const MODULE_CACHE_BUSTER = '20260603-webgl-right-page-text';
|
||||
const MODULE_CACHE_BUSTER = '20260606-webgl-direct-page-crop-coords';
|
||||
window.MODULE_CACHE_BUSTER = MODULE_CACHE_BUSTER;
|
||||
|
||||
/**
|
||||
|
||||
@@ -53,7 +53,8 @@ class OptionsUIModule extends BaseModule {
|
||||
'getPreference',
|
||||
'updatePreference',
|
||||
'updateUIText',
|
||||
'renderProviderStatuses'
|
||||
'renderProviderStatuses',
|
||||
'updateWebGLDisplays'
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -250,6 +251,86 @@ class OptionsUIModule extends BaseModule {
|
||||
|
||||
body.appendChild(appSettingsSection);
|
||||
|
||||
const webglSection = document.createElement('div');
|
||||
webglSection.className = 'options-section';
|
||||
|
||||
const webglTitle = document.createElement('h3');
|
||||
webglTitle.textContent = this.t('options.bookDisplay');
|
||||
webglSection.appendChild(webglTitle);
|
||||
|
||||
const displayModeContainer = document.createElement('div');
|
||||
displayModeContainer.className = 'option-item';
|
||||
|
||||
const displayModeLabel = document.createElement('label');
|
||||
displayModeLabel.textContent = this.t('options.displayMode') + ':';
|
||||
displayModeContainer.appendChild(displayModeLabel);
|
||||
|
||||
this.elements.webglMode = createUIElement('select', {
|
||||
'data-pref-bind': 'webgl.mode'
|
||||
}, null, displayModeContainer);
|
||||
[
|
||||
{ value: '3d', label: this.t('options.displayMode3d') },
|
||||
{ value: '2d', label: this.t('options.displayMode2d') }
|
||||
].forEach((optionConfig) => {
|
||||
const option = document.createElement('option');
|
||||
option.value = optionConfig.value;
|
||||
option.textContent = optionConfig.label;
|
||||
this.elements.webglMode.appendChild(option);
|
||||
});
|
||||
webglSection.appendChild(displayModeContainer);
|
||||
|
||||
const bookSizeContainer = document.createElement('div');
|
||||
bookSizeContainer.className = 'option-item';
|
||||
|
||||
const bookSizeLabel = document.createElement('label');
|
||||
bookSizeLabel.textContent = this.t('options.bookSize') + ':';
|
||||
bookSizeContainer.appendChild(bookSizeLabel);
|
||||
|
||||
const bookSizeValue = document.createElement('span');
|
||||
bookSizeValue.className = 'slider-value';
|
||||
bookSizeValue.textContent = '300';
|
||||
this.elements.webglBookSizeValue = bookSizeValue;
|
||||
bookSizeContainer.appendChild(bookSizeValue);
|
||||
|
||||
this.elements.webglBookSize = createUIElement('input', {
|
||||
type: 'range',
|
||||
min: 40,
|
||||
max: 500,
|
||||
step: 10,
|
||||
value: 300,
|
||||
'data-pref-bind': 'webgl.bookPageCount',
|
||||
'data-pref-transform': 'integer:40,500'
|
||||
}, null, bookSizeContainer);
|
||||
this.elements.webglBookSize.addEventListener('input', () => this.updateWebGLDisplays());
|
||||
webglSection.appendChild(bookSizeContainer);
|
||||
|
||||
const bookProgressContainer = document.createElement('div');
|
||||
bookProgressContainer.className = 'option-item';
|
||||
|
||||
const bookProgressLabel = document.createElement('label');
|
||||
bookProgressLabel.textContent = this.t('options.bookProgress') + ':';
|
||||
bookProgressContainer.appendChild(bookProgressLabel);
|
||||
|
||||
const bookProgressValue = document.createElement('span');
|
||||
bookProgressValue.className = 'slider-value';
|
||||
bookProgressValue.textContent = '50%';
|
||||
this.elements.webglBookProgressValue = bookProgressValue;
|
||||
bookProgressContainer.appendChild(bookProgressValue);
|
||||
|
||||
this.elements.webglBookProgress = createUIElement('input', {
|
||||
type: 'range',
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
value: 50,
|
||||
'data-pref-bind': 'webgl.bookProgress',
|
||||
'data-pref-transform': 'range:0,1'
|
||||
}, null, bookProgressContainer);
|
||||
this.elements.webglBookProgress.addEventListener('input', () => this.updateWebGLDisplays());
|
||||
webglSection.appendChild(bookProgressContainer);
|
||||
|
||||
body.appendChild(webglSection);
|
||||
|
||||
// TTS Section
|
||||
const ttsSection = document.createElement('div');
|
||||
ttsSection.className = 'options-section';
|
||||
@@ -1020,6 +1101,7 @@ class OptionsUIModule extends BaseModule {
|
||||
console.log('Options UI: Preference bindings set up', this.bindings.length);
|
||||
this.updateSpeedDisplay();
|
||||
this.updateVolumeDisplays();
|
||||
this.updateWebGLDisplays();
|
||||
|
||||
// Add event listeners for side effects when preferences change
|
||||
document.addEventListener('preference-updated', (event) => {
|
||||
@@ -1115,6 +1197,10 @@ class OptionsUIModule extends BaseModule {
|
||||
this.populateVoices();
|
||||
}
|
||||
}
|
||||
|
||||
if (category === 'webgl') {
|
||||
this.updateWebGLDisplays();
|
||||
}
|
||||
if (key === 'speed' && this.elements.ttsSpeed) {
|
||||
this.updateSpeedDisplay();
|
||||
}
|
||||
@@ -1155,6 +1241,15 @@ class OptionsUIModule extends BaseModule {
|
||||
this.elements.musicDuckingAmountValue.textContent = `${this.elements.musicDuckingAmount.value}%`;
|
||||
}
|
||||
}
|
||||
|
||||
updateWebGLDisplays() {
|
||||
if (this.elements.webglBookSize && this.elements.webglBookSizeValue) {
|
||||
this.elements.webglBookSizeValue.textContent = String(this.elements.webglBookSize.value);
|
||||
}
|
||||
if (this.elements.webglBookProgress && this.elements.webglBookProgressValue) {
|
||||
this.elements.webglBookProgressValue.textContent = `${this.elements.webglBookProgress.value}%`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create the singleton instance
|
||||
|
||||
@@ -67,6 +67,11 @@ class PersistenceManagerModule extends BaseModule {
|
||||
localeUserOverride: false,
|
||||
speed: 1.0,
|
||||
autoplay: true,
|
||||
},
|
||||
webgl: {
|
||||
mode: null,
|
||||
bookPageCount: 300,
|
||||
bookProgress: 0.5
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ export const PROCEDURAL_BOOK = {
|
||||
PAGE_COUNT_STEP: 10,
|
||||
PAGE_LINE_SEGMENTS: 48,
|
||||
PAGE_DEPTH: 2.24,
|
||||
PAGE_WIDTH: 2.24 * 2 / 3,
|
||||
PAGE_WIDTH: 2.24 * 0.806,
|
||||
COVER_DEPTH: 2.30,
|
||||
OPEN_SEAM_GAP: 0.003,
|
||||
PROFILE: {
|
||||
|
||||
+261
-86
@@ -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();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -29,6 +29,12 @@
|
||||
"options.voice": "Stimme",
|
||||
"options.speed": "Tempo",
|
||||
"options.audio": "Audio",
|
||||
"options.bookDisplay": "Buchanzeige",
|
||||
"options.displayMode": "Anzeigemodus",
|
||||
"options.displayMode3d": "3D",
|
||||
"options.displayMode2d": "2D",
|
||||
"options.bookSize": "Buchgröße",
|
||||
"options.bookProgress": "Seitenstapel",
|
||||
"options.volume": "Lautstärke",
|
||||
"options.masterVolume": "Gesamtlautstärke",
|
||||
"options.speechVolume": "Sprachlautstärke",
|
||||
@@ -53,6 +59,17 @@
|
||||
"options.apiUrl": "API-URL",
|
||||
"options.model": "Modell",
|
||||
"options.requestTimeoutMs": "Anfrage-Timeout (ms)",
|
||||
"webgl.title": "Prozedurales Buch",
|
||||
"webgl.sceneLabel": "3D-Buchszene",
|
||||
"webgl.bookControls": "Buchsteuerung",
|
||||
"webgl.status3d": "3D-Szene",
|
||||
"webgl.status2d": "2D-Szene",
|
||||
"webgl.bookSize": "Seiten",
|
||||
"webgl.pageStackProgress": "Fortschritt",
|
||||
"webgl.fastBackward": "Schnell zurück",
|
||||
"webgl.backward": "Zurück",
|
||||
"webgl.forward": "Vorwärts",
|
||||
"webgl.fastForward": "Schnell vorwärts",
|
||||
"credits.button": "Credits",
|
||||
"credits.buttonTitle": "Mitwirkende und Lizenzen anzeigen",
|
||||
"credits.title": "Mitwirkende und Lizenzen",
|
||||
|
||||
@@ -29,6 +29,12 @@
|
||||
"options.voice": "Voice",
|
||||
"options.speed": "Speed",
|
||||
"options.audio": "Audio",
|
||||
"options.bookDisplay": "Book Display",
|
||||
"options.displayMode": "Display Mode",
|
||||
"options.displayMode3d": "3D",
|
||||
"options.displayMode2d": "2D",
|
||||
"options.bookSize": "Book Size",
|
||||
"options.bookProgress": "Page Stack",
|
||||
"options.volume": "Volume",
|
||||
"options.masterVolume": "Master Volume",
|
||||
"options.speechVolume": "Speech Volume",
|
||||
@@ -53,6 +59,17 @@
|
||||
"options.apiUrl": "API URL",
|
||||
"options.model": "Model",
|
||||
"options.requestTimeoutMs": "Request timeout (ms)",
|
||||
"webgl.title": "Procedural Book",
|
||||
"webgl.sceneLabel": "3D book scene",
|
||||
"webgl.bookControls": "Book controls",
|
||||
"webgl.status3d": "3D scene",
|
||||
"webgl.status2d": "2D scene",
|
||||
"webgl.bookSize": "Pages",
|
||||
"webgl.pageStackProgress": "Progress",
|
||||
"webgl.fastBackward": "Fast backward",
|
||||
"webgl.backward": "Backward",
|
||||
"webgl.forward": "Forward",
|
||||
"webgl.fastForward": "Fast forward",
|
||||
"credits.button": "credits",
|
||||
"credits.buttonTitle": "Show credits and third-party licenses",
|
||||
"credits.title": "Credits and Licenses",
|
||||
|
||||
Reference in New Issue
Block a user