Stabilize WebGL lighting lab

This commit is contained in:
2026-06-04 20:43:00 +02:00
parent 444acb6229
commit e5b00f7472
8 changed files with 1094 additions and 175 deletions
+535
View File
@@ -0,0 +1,535 @@
# WebGL 3D UI Specification And Implementation Plan
This document captures the agreed direction for the WebGL book UI. Later decisions override earlier ones. The purpose is to stop visual regressions and make future implementation work testable against an explicit contract.
## Current Goal
Build a beautiful, readable, extensible WebGL book interface for the interactive fiction UI.
The first stable milestone is a standalone 3D scene: an open book lying correctly on a polished wooden table, lit only by flickering candles, with the two existing application pages rendered as crisp dynamic textures on the actual book pages.
The later product goal is a procedural book UI that supports virtual scrolling, animated page flips, dynamic page stacks, and content backfilling across spreads.
## Non-Negotiable Workflow Rules
- Do not continue visual coding without a concrete plan for the current sprint.
- Before tuning a visual feature, first prove it is active.
- Debug views must prove the feature in isolation.
- Composite views must prove the feature affects the final image.
- If a feature is broken or inactive, do not treat it as an intensity/tuning problem.
- Do not replace a working feature while implementing a new one unless the replacement is proven better in debug and composite views.
- Do not commit untested visual changes.
- Commit stable major changes before starting a new topic.
- Do not leave orphaned dev servers, Node processes, Playwright processes, or browser automation processes.
- Do not regenerate expensive textures in memory during page load once the design asset is stable. Generate them once, save them to disk, and load static assets.
- Do not use fake visual shortcuts once a shader or proper rendering path has been agreed.
- Do not introduce fallback visual cheats when the agreed task fails. If the real technique is not working, stop, document the failure, and investigate the real technique.
- Do not let a fallback or diagnostic layer become part of the final composite unless it is explicitly approved as a permanent art-direction layer.
- When screenshot capture fails, fix the capture/test tooling before continuing visual iteration.
## Repository And Branch Rules
- Work is on branch `webgl`.
- Do not stage or commit unrelated `.env` changes.
- Temporary screenshots are not product assets and should not be committed unless explicitly requested.
- Generated static visual assets may be committed when they are part of the scene contract.
- Documentation changes must use `apply_patch`.
## Scene Structure
The final UI module must remain compatible with the rest of the project, but its rendered layout changes from a flat static page into a layered 3D interface.
Required structure:
- One WebGL canvas as the main scene.
- One top menu line over the canvas.
- One modal overview layer over the canvas.
- Existing modal behavior must remain compatible with the rest of the app.
- The 3D book pages must display the actual app content as dynamic textures, not overlays placed above the model.
During visual development:
- Use an independent standalone page for the 3D scene.
- The standalone page must not be burdened by the app loader, modal system, or unrelated runtime while the visual design is being finalized.
- The standalone scene must remain modular enough to later integrate back into the actual app shell.
## Book Requirements
### Current Milestone
- The book is procedural unless a suitable real model is found and integrated correctly.
- The book must look like a real open book, not a thin folio.
- The book must lie flat and straight on the table.
- The lower book edge should run parallel to the screen edge in the default reading camera.
- The camera angle must be shallow enough for readability, not steep overhead.
- The book must show the two current app pages as dynamic page textures.
- The page aspect ratio and content must mirror the original book display as closely as possible.
- The right page must not be mirrored or rotated incorrectly.
- Text must be crisp enough for reading.
- Page texture resolution must be high enough that the projected text remains crisp at intended camera distances.
- The old UI/html creation module must be kept for reference while the new module replaces it.
### Later Product Goal
The book should become a dynamic procedural object:
- Both left and right pages are used for content.
- Virtual scrolling drives page content.
- The last two spreads are backfilled with virtual content.
- Page flips animate between spreads.
- When the user scrolls far through history, multiple quick page animations can occur.
- The left stack height grows as reading progresses.
- The right stack height shrinks or remains tied to the remaining book thickness.
- Page stack thickness must visibly change as a progress marker.
- The right side begins as the thicker unread portion.
- The left side grows from a few pages toward parity with the right stack.
- Individual pages can animate between stacks.
- The architecture must allow page geometry, page textures, stack heights, and flip animation to be controlled separately.
## Page Texture Requirements
- Page content must be rendered into textures applied to the actual page geometry.
- No separate reading-surface overlay on top of the book model.
- The left page texture must contain the full left page of the old layout.
- The right page texture must contain the dynamic typeset text from the original module.
- Text and lines must respect the original page proportions.
- Texture capture/generation must not silently crop the content.
- The page texture pipeline must support future virtualized content.
## Camera Requirements
Default reading camera:
- The book lower edge is parallel to the screen edge.
- The angle is slightly oblique, natural, and less steep than an overhead view.
- The camera is close enough to make good use of the canvas.
- The pages remain readable.
- Candles remain visible as scene context without stealing focus.
Interactive camera controls:
- Mouse controls angle/orbit.
- WASD moves the camera target through the scene.
- Mouse wheel zooms.
- Controls must not make the book drift or animate annoyingly when idle.
## Table Requirements
- The table surface is polished dark wood.
- It must reflect the book, candles, flames, and environment.
- Reflection must be physically plausible for a flat polished surface.
- Reflections must not be offset in a way that breaks the mirror illusion.
- Table wood remains visible; reflection strength must not drown it out.
- The table uses a subtle normal map to avoid perfect mirror flatness.
- The normal map must be subtle, not a large wobble or warped surface.
- Dust and fingerprint/grease maps are separate concepts:
- Dust is tiny particles that slightly catch specular light.
- Grease/fingerprints are smears that affect reflectivity/roughness.
- Dust must not look like breadcrumbs, paint, or a color overlay.
- Grease/fingerprint marks must be filled, small relative to the table, and plausible.
- Fingerprints should suppress dust where fingers wiped the surface.
- Dust and grease must influence reflection/specular behavior, not merely base color.
- Static disk assets should be used for stable table maps.
## Environment Reflection Requirements
- The generated room/environment image is a spherical/equirectangular reflection map.
- It must be sampled as a 360 degree environment, not projected like a plane.
- The spherical orientation must be correct: floorboards or room features must not appear in nonsensical directions.
- The environment reflection applies to the table surface only unless explicitly intended elsewhere.
- It must not incorrectly reflect on the book cover.
- The environment map can contribute to candlelit ambient tone, but it must not become fake visible room geometry unless explicitly designed.
## Candle Requirements
There are three candles on the table.
Placement:
- Candles are asymmetrically placed.
- Candles have different heights.
- One candle is upper left.
- One candle is upper right.
- One candle is lower right.
- Candles must sit on the table, not float or sink.
- Candle flames must sit on the wicks, not inside the wax body.
Geometry:
- Candle bodies are cylindrical, not cut-off cones.
- Wicks are visible and correctly positioned.
- Flames use two-layer teardrop geometry:
- Hot white/yellow core.
- Transparent orange outer flame.
Flame shader:
- Animated noise/displacement.
- Alpha falloff.
- Gradient from blue/dark wick base to yellow core to orange tip.
- Subtle flame movement drives light motion and shadow motion.
Wax shader:
- Semi-translucent wax.
- Simulated subsurface scattering.
- Stronger light-dependent backscatter near the flame.
- Soft glow through the upper wax body.
- The wax material should be flame-aware.
- Candle reflection should preserve the wax look as much as possible in the mirror render.
## Lighting Requirements
- There is no window or other white external light source.
- Candles are the only direct light sources.
- The scene can include low warm ambient light representing candlelight reflected by room walls.
- Candle point lights must be positioned at the animated flame positions in 3D space.
- Lights should move subtly with the flames.
- Each candle light must affect all relevant objects unless deliberately excluded for a documented reason.
- The lighting on the book must be attributable to the candle lights and ambient candle bounce.
- No unexplained fake light patches.
## Shadow Requirements
This is currently a regression area. The candle shadow system was previously better, and it must be treated as a locked baseline unless a specific shadow-restoration sprint is active.
Required behavior:
- Candle cast shadows must exist in the final composite.
- The previous working candle shadow behavior must be restored.
- All three candle bodies must cast visible shadows.
- All three candle light/flame positions must participate.
- The book must cast shadows onto the table from all three candles.
- The candle shadows must respect wax translucency conceptually: wax transmission should soften/reduce the shadow rather than creating hard opaque cylinder shadows.
- Shadows should be soft and believable, not hard-edged cones or arbitrary blobs.
- Shadows must move subtly with the animated flame/light positions.
- Contact AO is not a substitute for candle cast shadows.
- Reflections are not a substitute for candle cast shadows.
- SSAO is not a substitute for candle cast shadows.
- Candle shadows must not be weakened, removed, or repurposed while working on SSAO.
Debug proof:
- `tableDebug=shadow` must clearly show shadow contribution from all three candles.
- The shadow debug must show all three candle bodies casting shadows from the relevant flame/light positions.
- The final composite must visibly include those shadows.
- If the debug view shows shadows but the composite does not, the feature is not complete.
Implementation note:
- The prior candle shadow behavior was lost during SSAO/compositing work.
- The shadow debug view was not literally repurposed to show contact AO, but contact AO was incorrectly used as evidence of scene grounding while the real shadow regression remained unresolved.
- `tableDebug=shadow` must remain dedicated to candle cast-shadow proof.
- If SSAO conflicts with existing cast shadows, SSAO loses until it is redesigned.
## Ambient Occlusion Requirements
AO must be separated conceptually from cast shadows.
Required behavior:
- AO darkens tight contact and crevice regions.
- Candle bottoms should have local contact occlusion.
- The book/table contact should have local occlusion.
- AO should not appear as broad painted darkness.
- AO should not treat flames or glow sprites as solid occluders.
- The book should be an equal participant in AO computations.
- Candle contact AO must come from the real AO solution, not from a hand-authored fallback masquerading as AO.
- Analytic contact darkening is not accepted as the solution for AO.
- The fallback analytic contact/shadow layer must be removed completely before continuing SSAO work.
Current status:
- Scene-level SSAO has been added but is visually weak.
- The visible candle-bottom effect is currently analytic table-shader contact AO. This is a failed fallback, not an accepted feature.
- SSAO is not yet good enough to be considered the solved AO system.
- `tableDebug=ao` currently proves only that a Three.js `SSAOPass` is wired. It does not prove useful visual AO.
- The SSAO attempt has not yet produced a meaningful composite result.
- The analytic contact fallback confused the evaluation and must be removed so SSAO can be judged honestly.
Debug proof:
- `tableDebug=ao` should show scene-level SSAO.
- `tableDebug=contact` is deprecated with the analytic fallback. If retained temporarily during cleanup, it must be labeled as deprecated diagnostic output and must not affect the final composite.
- `tableDebug=shadow` must show cast shadows only.
- Debug views must be unambiguous.
- The final composite must show the intended contribution.
SSAO investigation requirements:
1. Establish what the chosen SSAO/GTAO/HBAO implementation is supposed to compute.
2. Identify its required inputs: depth, normals, camera projection, radius, scale, falloff, render target size, and pass order.
3. Verify that the scene actually provides those inputs correctly.
4. Verify that the AO pass sees the table, book, and candle wax bodies.
5. Verify that flames and glow sprites are excluded from occlusion.
6. Determine why the current `SSAOPass` output is nearly white and visually weak.
7. Fix the root cause before tuning intensity.
8. Prove the fixed AO in `tableDebug=ao`.
9. Prove the fixed AO in the normal composite.
10. Only then decide whether Three.js `SSAOPass` is sufficient or whether a custom GTAO/HBAO-style pass is needed.
Known SSAO failure hypotheses to test:
- AO radius may be wrong for the scene scale.
- The pass may lack useful normal data for the current materials/geometries.
- The table, book, or candles may be positioned or scaled such that the depth differences are too small for the current AO parameters.
- Postprocessing pass order or output mode may dilute the AO before it reaches the final image.
- Tone mapping/exposure may wash out AO.
- The table shader and reflection composite may overwrite or hide AO contribution.
- Flame/glow exclusion may be correct, but wax/book/table inclusion must be verified.
- The debug output may be too low contrast to judge without a calibrated visualization.
## Reflection Requirements
Table reflections:
- Use a real planar reflection camera/mirror render path.
- The reflection must include book, candles, wax bodies, wicks, flames, and environment contribution where appropriate.
- Reflected flames should be smaller/warmer and partly occluded by reflected wax bodies.
- Reflections must be crisp enough for the scene quality target.
- Reflection render target resolution should favor quality over performance.
- Anti-aliasing or higher render target resolution should be used if reflections look jagged.
Modern reflection note:
- A manual mirrored camera is acceptable only if alignment is correct.
- A proper oblique reflection matrix/clip plane may be preferred for robust modern planar reflection.
- If reflection alignment regresses, investigate the reflection camera/projection math first.
## Anti-Aliasing And Image Quality
- Image quality is prioritized over performance for this scene.
- The target hardware assumption is strong enough for three candles and high-quality shader work.
- The scene should use high-quality anti-aliasing for the main render and reflection render.
- Text on pages must remain crisp.
- Shader and render target choices must avoid visible jaggedness, especially after adding SSAO or postprocessing.
- If postprocessing causes aliasing, fix the pass order/resolution rather than accepting degradation.
## Debug Views
The standalone scene should support debug query modes:
- `tableDebug=shadow`: candle cast-shadow contribution.
- `tableDebug=ao`: scene-level SSAO.
- `tableDebug=normal`: table normal map.
- `tableDebug=dust`: dust map/effect.
- `tableDebug=grease`: grease/fingerprint map/effect.
- `tableDebug=room`: environment reflection contribution.
- `tableDebug=scene`: planar scene reflection.
- `tableDebug=mask`: table reflection mask.
Deprecated:
- `tableDebug=contact`: analytic contact fallback diagnostic. This must be removed with the fallback contact layer unless explicitly retained as a temporary cleanup check.
Debug views must be visually meaningful. A debug view that is too subtle to interpret is not useful proof.
## Testing And Verification Requirements
Before reporting a visual feature as complete:
1. Run static/regression checks.
2. Run the build.
3. Capture a debug screenshot proving the feature exists in isolation.
4. Capture a normal composite screenshot proving it affects the final image.
5. Inspect the screenshots visually.
6. Check that no rogue browser/Node/Playwright processes were left behind.
7. Report honestly what is proven and what remains weak.
Screenshot tooling:
- Use the in-app browser tool when available.
- If using Playwright, use one browser instance and close it in `finally`.
- Use bounded timeouts.
- If screenshot readback stalls, fix the capture method before continuing visual iteration.
- Do not start multiple servers or leave orphaned processes.
## Implementation Plan
### Phase 0: Stabilize Current Work State
1. Stop all scene code changes.
2. Keep this specification as the governing document.
3. Do not commit any visual change until the current regression is fixed and tested.
4. Preserve unrelated `.env` changes unstaged.
5. Remove or ignore temporary screenshot files unless needed for explicit review.
### Phase 1: Remove The Analytic Contact Fallback
Goal: remove the fallback contact-shadow/contact-AO cheat so the scene can be evaluated honestly.
Steps:
1. Remove analytic contact darkening from the final table composite.
2. Remove `surfaceContactOcclusion`, `candleContact`, and `candleFootOcclusion` from the final image unless they are only part of a temporary diagnostic that does not affect normal rendering.
3. Remove or clearly deprecate `tableDebug=contact`.
4. Confirm `tableDebug=shadow` still shows only candle cast shadows.
5. Confirm `tableDebug=ao` still shows only scene-level SSAO.
6. Capture before/after screenshots proving the fallback was removed.
7. Run checks and build.
Acceptance criteria:
- The normal composite no longer contains analytic contact fallback darkening.
- Contact darkening is not used as a substitute for SSAO or cast shadows.
- Any remaining grounding must come from real shadows, real SSAO, or accepted reflection/lighting behavior.
- Debug modes are no longer ambiguous.
### Phase 2: Restore And Protect Candle Cast Shadows
Goal: restore the candle shadows that were working before SSAO work.
Steps:
1. Inspect the last known good candle-shadow checkpoint.
2. Identify exactly which changes removed or masked candle shadows.
3. Restore shadow contribution without disturbing accepted table dust/grease/reflection work.
4. Verify whether `tableMesh.receiveShadow = false` is still valid. If primitive shadow receiving was part of the working candle shadows, restore it or provide a proven shader replacement.
5. Verify whether candle wax meshes need to cast primitive shadows or whether analytic shader shadows fully replace them.
6. Ensure all three candle flame positions and all three candle bodies are included.
7. Ensure the book participates in candle shadows.
8. Use `tableDebug=shadow` to prove the shadow field.
9. Use the normal composite to prove shadows are visible.
10. Run checks and build.
11. Commit only after visual proof.
Acceptance criteria:
- All three candles cast visible shadows.
- Shadows are visible in final composite.
- Shadows are soft, not hard black cones.
- No analytic contact fallback is used to fake shadows.
- Reflections, dust, grease, normal map, and page textures remain intact.
### Phase 3: Implement True SSAO
Goal: understand, fix, and complete real scene-level SSAO.
Steps:
1. Read the Three.js `SSAOPass` behavior and document what data it uses.
2. Verify pass order, render target resolution, camera near/far values, and depth/normal availability.
3. Create a calibrated `tableDebug=ao` view that makes AO contribution readable.
4. Verify that table, book, and candle wax bodies participate.
5. Verify that flames and glow sprites do not participate as occluders.
6. Tune scene scale/radius/falloff only after the pass is proven active.
7. If Three.js `SSAOPass` cannot produce the required effect, replace it with a better GTAO/HBAO-style implementation.
8. Prove AO in debug.
9. Prove AO in final composite.
10. Add regression checks that prevent analytic contact fallback from being reintroduced.
Acceptance criteria:
- Candle bases show local AO from the real AO pass.
- Book/table contact shows local AO from the real AO pass.
- The book, table, and candles are equal scene participants.
- Scene AO contributes visible crevice/contact depth without becoming broad dirt or painted shadow.
- AO does not replace cast shadows.
- No fallback contact darkening remains in the final composite.
### Phase 4: Reflection And Compositing Cleanup
Goal: make the table reflection physically coherent.
Steps:
1. Confirm planar reflection camera alignment.
2. Evaluate oblique clip-plane reflection if current mirror math remains fragile.
3. Ensure book, candle bodies, wicks, and flames are all reflected.
4. Ensure candle body reflection can occlude reflected flame glare.
5. Ensure environment reflection is correctly oriented and table-only.
6. Tune dust and grease as roughness/specular modifiers only.
7. Prove each contribution in debug and final composite.
Acceptance criteria:
- Reflections align with real object positions.
- Candle bodies are visible in reflections.
- Flame reflection does not appear as an impossible unoccluded blob.
- Table wood remains visible.
### Phase 5: Page Texture Quality
Goal: make the book usable as an actual reading UI.
Steps:
1. Audit old layout page dimensions.
2. Create high-resolution page texture canvases.
3. Render left and right old-layout content into those textures.
4. Preserve text orientation and aspect ratio.
5. Verify crispness at default and close camera distances.
6. Add debug/export view for page textures.
Acceptance criteria:
- Text is crisp.
- Left and right pages match old layout content and proportions.
- No mirrored or rotated page.
- No overlay reading surface.
### Phase 6: Integration Back Into App Shell
Goal: connect the standalone scene back to the real app.
Steps:
1. Keep the standalone lab page for visual regression.
2. Replace the current UI/html creation module with the WebGL module.
3. Keep the old module for reference.
4. Add one top menu line over the canvas.
5. Add modal overview layer over the canvas.
6. Keep compatibility with existing app state, modals, and dynamic text.
Acceptance criteria:
- Existing app behavior still works.
- Canvas scene renders the book UI.
- Top menu and modal overview are available.
- Dynamic text appears on actual page textures.
### Phase 7: Procedural Page System
Goal: implement the future virtual-scrolling book.
Steps:
1. Build procedural page stack geometry.
2. Add controllable page flip animation.
3. Add independent left/right page texture assignment.
4. Connect virtual scroll position to page/spread state.
5. Backfill recent spreads from virtual content.
6. Animate stack thickness changes during navigation.
7. Support fast multi-page transitions when jumping through history.
Acceptance criteria:
- Page stacks visibly represent progress.
- Page flips are controllable and stop at the intended spread.
- Both pages show dynamic content.
- Virtual scrolling and page animation feel like one system.
## Current Known Problems
- Candle cast shadows are missing/regressed.
- Scene-level SSAO is visually weak.
- Analytic candle/book contact darkening exists as a fallback cheat and must be removed.
- Candle contact AO is not a substitute for shadows or true SSAO.
- The current shadow/composite path must be repaired before further visual polish.
- Reflection and dust/grease work are acceptable enough to preserve while fixing shadows.
- Screenshot capture can stall at large viewport sizes; current reliable method uses smaller viewport and explicit screenshot timeout.
## Next Immediate Task
Remove the analytic contact fallback completely, then investigate and fix true SSAO.
No other visual topic should be started until the following are true:
- The final composite no longer uses analytic contact fallback darkening.
- `tableDebug=contact` is removed or explicitly marked deprecated and disconnected from the final composite.
- `tableDebug=ao` is understood, documented, and made visually meaningful.
- The cause of the weak SSAO output is identified before intensity tuning.
- Any SSAO fix is proven in both debug and final composite.
- Existing candle shadows are not weakened or repurposed during SSAO work.
- Static checks and build pass.
- No orphaned processes remain.
+2
View File
@@ -40,6 +40,8 @@
"pretest-server": "npm run check:node",
"test-server": "ts-node src/test-server-yaml.ts",
"build": "tsc",
"generate:webgl-assets": "python scripts/generate-webgl-table-assets.py",
"check:webgl-lab": "node scripts/check-webgl-book-lab.js",
"test": "jest",
"lint": "eslint --ext .ts src/",
"lint:fix": "eslint --ext .ts src/ --fix"
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

+411 -174
View File
@@ -1,4 +1,9 @@
import * as THREE from 'https://esm.sh/three@0.165.0';
import { EffectComposer } from 'https://esm.sh/three@0.165.0/examples/jsm/postprocessing/EffectComposer.js';
import { RenderPass } from 'https://esm.sh/three@0.165.0/examples/jsm/postprocessing/RenderPass.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 { OutputPass } from 'https://esm.sh/three@0.165.0/examples/jsm/postprocessing/OutputPass.js';
const canvas = document.getElementById('scene');
canvas.style.cursor = 'grab';
@@ -18,7 +23,7 @@ const tableDebugName = urlParams.get('tableDebug') || 'none';
const tableDebugMode = tableDebugModes[tableDebugName] ?? tableDebugModes.none;
const labStatus = document.getElementById('lab_status');
if (labStatus && tableDebugMode !== tableDebugModes.none) {
labStatus.textContent = `table debug: ${tableDebugName}`;
labStatus.textContent = tableDebugName === 'ao' ? 'scene debug: SSAO' : `table debug: ${tableDebugName}`;
}
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
@@ -32,6 +37,13 @@ const generatedTextureCanvases = {};
const maxTextureAnisotropy = renderer.capabilities.getMaxAnisotropy();
const reflectionPixelRatio = Math.min(window.devicePixelRatio || 1, 2);
const reflectionTargetSize = new THREE.Vector2();
let sceneComposerTarget = null;
let composer = null;
let sceneRenderPass = null;
let sceneAoPass = null;
let sceneSmaaPass = null;
let sceneOutputPass = null;
const aoExcludedObjects = [];
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x080604);
@@ -66,8 +78,33 @@ const reflectionUp = new THREE.Vector3();
const candleShadowSources = [];
const candleWorldPosition = new THREE.Vector3();
const flameWorldPosition = new THREE.Vector3();
const bookShadowMapSize = 1536;
const bookShadowTargets = Array.from({ length: 3 }, () => {
const target = new THREE.WebGLRenderTarget(bookShadowMapSize, bookShadowMapSize, {
colorSpace: THREE.NoColorSpace,
depthBuffer: true,
stencilBuffer: false
});
target.texture.colorSpace = THREE.NoColorSpace;
target.texture.minFilter = THREE.LinearFilter;
target.texture.magFilter = THREE.LinearFilter;
target.texture.generateMipmaps = false;
return target;
});
const bookShadowCameras = Array.from({ length: 3 }, () => new THREE.PerspectiveCamera(78, 1, 0.03, 7.2));
const bookShadowMatrices = Array.from({ length: 3 }, () => new THREE.Matrix4());
const bookShadowBiasMatrix = new THREE.Matrix4().set(
0.5, 0, 0, 0.5,
0, 0.5, 0, 0.5,
0, 0, 0.5, 0.5,
0, 0, 0, 1
);
const bookShadowDepthMaterial = new THREE.MeshDepthMaterial({
depthPacking: THREE.RGBADepthPacking
});
bookShadowDepthMaterial.blending = THREE.NoBlending;
const camera = new THREE.PerspectiveCamera(28, 1, 0.1, 80);
const camera = new THREE.PerspectiveCamera(28, 1, 0.1, 40);
const cameraRig = {
target: new THREE.Vector3(0, 0.16, -0.04),
yaw: 0,
@@ -89,6 +126,8 @@ if (urlParams.get('view') === 'wide') {
}
updateCameraRig(0);
configureScenePostprocessing();
const clock = new THREE.Clock();
const book = new THREE.Group();
scene.add(book);
@@ -153,12 +192,27 @@ const materials = {
})
};
configureBookShadowReceiver(materials.leather, 0.52);
configureBookShadowReceiver(materials.coverEdge, 0.42);
configureBookShadowReceiver(materials.pageBlock, 0.46);
configureBookShadowReceiver(materials.pageEdge, 0.34);
configureBookShadowReceiver(materials.leftPage, 0.38);
configureBookShadowReceiver(materials.rightPage, 0.38);
buildTable();
buildLighting();
buildBook();
loadAiRoomReflection();
window.BookLabDebug = {
textures: generatedTextureCanvases,
ready: false,
renderedFrames: 0,
get sceneAoPass() {
return sceneAoPass;
},
get composer() {
return composer;
},
exportTexture(name) {
return generatedTextureCanvases[name]?.toDataURL('image/png') || null;
}
@@ -176,16 +230,16 @@ function buildTable() {
tableTexture.wrapT = THREE.RepeatWrapping;
tableTexture.repeat.set(2.15, 1.45);
tableTexture.anisotropy = renderer.capabilities.getMaxAnisotropy();
const tableNormal = createTableNormalTexture();
const tableNormal = loadUtilityTexture('/assets/webgl/table_normal_2k.png');
tableNormal.wrapS = THREE.RepeatWrapping;
tableNormal.wrapT = THREE.RepeatWrapping;
tableNormal.repeat.set(2.15, 1.45);
tableNormal.anisotropy = renderer.capabilities.getMaxAnisotropy();
tableDustTexture = createTableDustTexture();
tableDustTexture = loadUtilityTexture('/assets/webgl/table_dust_4k.png');
tableDustTexture.wrapS = THREE.ClampToEdgeWrapping;
tableDustTexture.wrapT = THREE.ClampToEdgeWrapping;
tableDustTexture.anisotropy = renderer.capabilities.getMaxAnisotropy();
tableGreaseTexture = createTableGreaseTexture();
tableGreaseTexture = loadUtilityTexture('/assets/webgl/table_grease_4k.png');
tableGreaseTexture.wrapS = THREE.ClampToEdgeWrapping;
tableGreaseTexture.wrapT = THREE.ClampToEdgeWrapping;
tableGreaseTexture.anisotropy = renderer.capabilities.getMaxAnisotropy();
@@ -205,10 +259,172 @@ function buildTable() {
configureTableShader(tableMaterial);
tableMesh = new THREE.Mesh(new THREE.BoxGeometry(9.8, 0.28, 6.6, 1, 1, 1), tableMaterial);
tableMesh.position.y = -0.16;
tableMesh.receiveShadow = true;
tableMesh.receiveShadow = false;
scene.add(tableMesh);
}
function loadUtilityTexture(url) {
const texture = new THREE.TextureLoader().load(url);
texture.colorSpace = THREE.NoColorSpace;
texture.minFilter = THREE.LinearMipmapLinearFilter;
texture.magFilter = THREE.LinearFilter;
texture.generateMipmaps = true;
return texture;
}
function configureBookShadowReceiver(material, strength) {
material.customProgramCacheKey = () => `book-shadow-receiver-${strength.toFixed(2)}`;
material.onBeforeCompile = (shader) => {
shader.uniforms.bookShadowMaps = { value: bookShadowTargets.map((target) => target.texture) };
shader.uniforms.bookShadowMatrices = { value: bookShadowMatrices };
shader.uniforms.bookShadowMapTexelSize = { value: new THREE.Vector2(1 / bookShadowMapSize, 1 / bookShadowMapSize) };
shader.uniforms.bookShadowReceiverStrength = { value: strength };
shader.vertexShader = shader.vertexShader
.replace(
'#include <common>',
'#include <common>\nvarying vec3 vBookReceiverWorldPosition;'
)
.replace(
'#include <project_vertex>',
'vBookReceiverWorldPosition = (modelMatrix * vec4(transformed, 1.0)).xyz;\n#include <project_vertex>'
);
shader.fragmentShader = shader.fragmentShader
.replace(
'#include <common>',
`#include <common>
uniform sampler2D bookShadowMaps[3];
uniform mat4 bookShadowMatrices[3];
uniform vec2 bookShadowMapTexelSize;
uniform float bookShadowReceiverStrength;
varying vec3 vBookReceiverWorldPosition;
float bookReceiverUnpackRGBADepth(vec4 packedDepth) {
const vec4 unpackFactors = vec4(
1.0 / (256.0 * 256.0 * 256.0),
1.0 / (256.0 * 256.0),
1.0 / 256.0,
1.0
);
return dot(packedDepth, unpackFactors);
}
float bookReceiverCompare(vec4 packedDepth, float currentDepth) {
float closestDepth = bookReceiverUnpackRGBADepth(packedDepth);
return smoothstep(0.003, 0.022, currentDepth - closestDepth - 0.0045);
}
float bookReceiverSample0(vec4 shadowCoord) {
vec3 coord = shadowCoord.xyz / max(shadowCoord.w, 0.0001);
float inBounds = step(0.0, coord.x) * step(0.0, coord.y) *
step(coord.x, 1.0) * step(coord.y, 1.0) * step(0.0, coord.z) * step(coord.z, 1.0);
if (inBounds < 0.5) return 0.0;
float shadow = 0.0;
for (int x = -1; x <= 1; x++) {
for (int y = -1; y <= 1; y++) {
vec2 offset = vec2(float(x), float(y)) * bookShadowMapTexelSize * 1.35;
shadow += bookReceiverCompare(texture2D(bookShadowMaps[0], coord.xy + offset), coord.z);
}
}
return clamp(shadow / 9.0, 0.0, 1.0) * inBounds;
}
float bookReceiverSample1(vec4 shadowCoord) {
vec3 coord = shadowCoord.xyz / max(shadowCoord.w, 0.0001);
float inBounds = step(0.0, coord.x) * step(0.0, coord.y) *
step(coord.x, 1.0) * step(coord.y, 1.0) * step(0.0, coord.z) * step(coord.z, 1.0);
if (inBounds < 0.5) return 0.0;
float shadow = 0.0;
for (int x = -1; x <= 1; x++) {
for (int y = -1; y <= 1; y++) {
vec2 offset = vec2(float(x), float(y)) * bookShadowMapTexelSize * 1.35;
shadow += bookReceiverCompare(texture2D(bookShadowMaps[1], coord.xy + offset), coord.z);
}
}
return clamp(shadow / 9.0, 0.0, 1.0) * inBounds;
}
float bookReceiverSample2(vec4 shadowCoord) {
vec3 coord = shadowCoord.xyz / max(shadowCoord.w, 0.0001);
float inBounds = step(0.0, coord.x) * step(0.0, coord.y) *
step(coord.x, 1.0) * step(coord.y, 1.0) * step(0.0, coord.z) * step(coord.z, 1.0);
if (inBounds < 0.5) return 0.0;
float shadow = 0.0;
for (int x = -1; x <= 1; x++) {
for (int y = -1; y <= 1; y++) {
vec2 offset = vec2(float(x), float(y)) * bookShadowMapTexelSize * 1.35;
shadow += bookReceiverCompare(texture2D(bookShadowMaps[2], coord.xy + offset), coord.z);
}
}
return clamp(shadow / 9.0, 0.0, 1.0) * inBounds;
}
float bookReceiverShadowField(vec3 worldPosition) {
float shadow0 = bookReceiverSample0(bookShadowMatrices[0] * vec4(worldPosition, 1.0));
float shadow1 = bookReceiverSample1(bookShadowMatrices[1] * vec4(worldPosition, 1.0));
float shadow2 = bookReceiverSample2(bookShadowMatrices[2] * vec4(worldPosition, 1.0));
return clamp(max(max(shadow0, shadow1), shadow2), 0.0, 1.0);
}`
)
.replace(
'#include <opaque_fragment>',
`float bookReceiverShadow = bookReceiverShadowField(vBookReceiverWorldPosition) * bookShadowReceiverStrength;
outgoingLight *= mix(vec3(1.0), vec3(0.38, 0.29, 0.2), bookReceiverShadow);
#include <opaque_fragment>`
);
};
}
function configureScenePostprocessing() {
sceneComposerTarget = new THREE.WebGLRenderTarget(1, 1, {
colorSpace: THREE.SRGBColorSpace,
depthBuffer: true,
stencilBuffer: false,
samples: renderer.capabilities.isWebGL2 ? 8 : 0
});
sceneComposerTarget.texture.colorSpace = THREE.SRGBColorSpace;
sceneComposerTarget.texture.minFilter = THREE.LinearFilter;
sceneComposerTarget.texture.magFilter = THREE.LinearFilter;
composer = new EffectComposer(renderer, sceneComposerTarget);
composer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
sceneRenderPass = new RenderPass(scene, camera);
composer.addPass(sceneRenderPass);
sceneAoPass = new SSAOPass(scene, camera, 1, 1, 64);
sceneAoPass.kernelRadius = 0.48;
sceneAoPass.minDistance = 0.00025;
sceneAoPass.maxDistance = 0.065;
if (tableDebugName === 'ao' && SSAOPass.OUTPUT?.Blur !== undefined) {
sceneAoPass.output = SSAOPass.OUTPUT.Blur;
} else if (SSAOPass.OUTPUT?.Default !== undefined) {
sceneAoPass.output = SSAOPass.OUTPUT.Default;
}
const renderAoPass = sceneAoPass.render.bind(sceneAoPass);
sceneAoPass.render = (...args) => {
aoExcludedObjects.forEach((object) => {
object.userData.wasVisibleForAo = object.visible;
object.visible = false;
});
try {
renderAoPass(...args);
} finally {
aoExcludedObjects.forEach((object) => {
object.visible = object.userData.wasVisibleForAo;
delete object.userData.wasVisibleForAo;
});
}
};
composer.addPass(sceneAoPass);
sceneSmaaPass = new SMAAPass(1, 1);
composer.addPass(sceneSmaaPass);
sceneOutputPass = new OutputPass();
composer.addPass(sceneOutputPass);
}
function buildLighting() {
scene.add(new THREE.AmbientLight(0x120b06, 0.22));
@@ -246,6 +462,8 @@ function addCandle(x, y, z, intensity, height) {
waxGlow.position.copy(wax.position);
waxGlow.castShadow = false;
waxGlow.receiveShadow = false;
waxGlow.userData.excludeFromAo = true;
aoExcludedObjects.push(waxGlow);
candle.add(waxGlow);
const wickTopY = height + 0.075;
@@ -261,10 +479,17 @@ function addCandle(x, y, z, intensity, height) {
);
wick.position.y = height + 0.015;
wick.rotation.x = 0.16;
wick.castShadow = false;
wick.receiveShadow = false;
candle.add(wick);
const flame = createFlame();
flame.position.y = wickTopY + 0.055;
flame.userData.excludeFromAo = true;
flame.traverse((child) => {
child.userData.excludeFromAo = true;
aoExcludedObjects.push(child);
});
candle.add(flame);
const baseLightIntensity = intensity * 7.4;
@@ -509,7 +734,7 @@ function createWaxMaterial(height) {
}
function configureTableShader(material) {
material.customProgramCacheKey = () => 'book-lab-table-planar-environment-reflection-v2';
material.customProgramCacheKey = () => 'book-lab-table-planar-environment-reflection-v7';
material.onBeforeCompile = (shader) => {
tableShader = shader;
shader.uniforms.roomReflectionMap = { value: tableRoomReflectionTexture };
@@ -526,6 +751,9 @@ function configureTableShader(material) {
shader.uniforms.candleBodyData = {
value: [new THREE.Vector2(), new THREE.Vector2(), new THREE.Vector2()]
};
shader.uniforms.bookShadowMaps = { value: bookShadowTargets.map((target) => target.texture) };
shader.uniforms.bookShadowMatrices = { value: bookShadowMatrices };
shader.uniforms.bookShadowMapTexelSize = { value: new THREE.Vector2(1 / bookShadowMapSize, 1 / bookShadowMapSize) };
shader.uniforms.tableDebugMode = { value: tableDebugMode };
shader.vertexShader = shader.vertexShader
@@ -550,6 +778,9 @@ function configureTableShader(material) {
uniform vec3 candleBodyPositions[3];
uniform vec3 candleFlamePositions[3];
uniform vec2 candleBodyData[3];
uniform sampler2D bookShadowMaps[3];
uniform mat4 bookShadowMatrices[3];
uniform vec2 bookShadowMapTexelSize;
uniform int tableDebugMode;
varying vec3 vTableWorldPosition;
varying vec4 vSceneReflectionCoord;
@@ -634,20 +865,6 @@ function configureTableShader(material) {
return clamp(max(exactHit, softHit * 0.72) * vertical * selfShadowLimiter * bodyOpacity, 0.0, 0.42);
}
float candleContactOcclusion(vec3 point, vec3 body, vec2 bodyData) {
vec2 delta = point.xz - body.xz;
float base = 1.0 - smoothstep(bodyData.x * 0.72, bodyData.x * 2.55, length(delta));
return base * 0.32;
}
float candleContactField(vec3 point) {
float contact = 0.0;
for (int bodyIndex = 0; bodyIndex < 3; bodyIndex++) {
contact = max(contact, candleContactOcclusion(point, candleBodyPositions[bodyIndex], candleBodyData[bodyIndex]));
}
return clamp(contact, 0.0, 0.36);
}
float candleProjectedShadowField(vec3 point) {
float projectedShadow = 0.0;
for (int bodyIndex = 0; bodyIndex < 3; bodyIndex++) {
@@ -657,6 +874,109 @@ function configureTableShader(material) {
}
}
return clamp(projectedShadow, 0.0, 0.46);
}
float candlePlanarShadowLobe(vec3 point, vec3 flame, vec3 body, vec2 bodyData, float selfLight) {
vec2 lightToBody = body.xz - flame.xz;
float lightDistance = length(lightToBody);
vec2 direction = lightDistance > 0.012 ? lightToBody / lightDistance : normalize(vec2(0.42, 0.91));
vec2 perpendicular = vec2(-direction.y, direction.x);
vec2 delta = point.xz - body.xz;
float along = dot(delta, direction);
float side = abs(dot(delta, perpendicular));
float radius = bodyData.x;
float softContact = 1.0 - smoothstep(radius * 0.72, radius * 3.4, length(delta));
float lobeLength = mix(radius * 3.4, radius * (4.8 + lightDistance * 0.92), 1.0 - selfLight);
float lobeWidth = radius * (1.6 + max(along, 0.0) * mix(0.72, 0.46, selfLight));
float frontGate = smoothstep(-radius * 0.42, radius * 0.34, along);
float distanceFade = 1.0 - smoothstep(radius * 0.7, lobeLength, along);
float sideFade = 1.0 - smoothstep(lobeWidth * 0.54, lobeWidth, side);
float directional = frontGate * distanceFade * sideFade;
float heightFade = smoothstep(body.y - 0.02, body.y + bodyData.y * 0.72, flame.y);
float waxTransmission = mix(0.54, 0.78, selfLight);
float strength = mix(0.32, 0.2, selfLight) * heightFade * (1.0 - waxTransmission * 0.48);
return clamp(max(softContact * 0.2, directional * strength), 0.0, 0.38);
}
float candlePlanarShadowField(vec3 point) {
float shadow = 0.0;
for (int bodyIndex = 0; bodyIndex < 3; bodyIndex++) {
for (int flameIndex = 0; flameIndex < 3; flameIndex++) {
float selfLight = bodyIndex == flameIndex ? 1.0 : 0.0;
shadow = max(shadow, candlePlanarShadowLobe(point, candleFlamePositions[flameIndex], candleBodyPositions[bodyIndex], candleBodyData[bodyIndex], selfLight));
}
}
return clamp(shadow, 0.0, 0.5);
}
float bookUnpackRGBADepth(vec4 packedDepth) {
const vec4 unpackFactors = vec4(
1.0 / (256.0 * 256.0 * 256.0),
1.0 / (256.0 * 256.0),
1.0 / 256.0,
1.0
);
return dot(packedDepth, unpackFactors);
}
float bookShadowCompare(vec4 packedDepth, float currentDepth) {
float closestDepth = bookUnpackRGBADepth(packedDepth);
return smoothstep(0.001, 0.018, currentDepth - closestDepth - 0.0018);
}
float bookShadowSample0(vec4 shadowCoord) {
vec3 coord = shadowCoord.xyz / max(shadowCoord.w, 0.0001);
float inBounds = step(0.0, coord.x) * step(0.0, coord.y) *
step(coord.x, 1.0) * step(coord.y, 1.0) * step(0.0, coord.z) * step(coord.z, 1.0);
if (inBounds < 0.5) return 0.0;
float shadow = 0.0;
for (int x = -1; x <= 1; x++) {
for (int y = -1; y <= 1; y++) {
vec2 offset = vec2(float(x), float(y)) * bookShadowMapTexelSize * 1.65;
shadow += bookShadowCompare(texture2D(bookShadowMaps[0], coord.xy + offset), coord.z);
}
}
return clamp(shadow / 9.0, 0.0, 1.0) * inBounds;
}
float bookShadowSample1(vec4 shadowCoord) {
vec3 coord = shadowCoord.xyz / max(shadowCoord.w, 0.0001);
float inBounds = step(0.0, coord.x) * step(0.0, coord.y) *
step(coord.x, 1.0) * step(coord.y, 1.0) * step(0.0, coord.z) * step(coord.z, 1.0);
if (inBounds < 0.5) return 0.0;
float shadow = 0.0;
for (int x = -1; x <= 1; x++) {
for (int y = -1; y <= 1; y++) {
vec2 offset = vec2(float(x), float(y)) * bookShadowMapTexelSize * 1.65;
shadow += bookShadowCompare(texture2D(bookShadowMaps[1], coord.xy + offset), coord.z);
}
}
return clamp(shadow / 9.0, 0.0, 1.0) * inBounds;
}
float bookShadowSample2(vec4 shadowCoord) {
vec3 coord = shadowCoord.xyz / max(shadowCoord.w, 0.0001);
float inBounds = step(0.0, coord.x) * step(0.0, coord.y) *
step(coord.x, 1.0) * step(coord.y, 1.0) * step(0.0, coord.z) * step(coord.z, 1.0);
if (inBounds < 0.5) return 0.0;
float shadow = 0.0;
for (int x = -1; x <= 1; x++) {
for (int y = -1; y <= 1; y++) {
vec2 offset = vec2(float(x), float(y)) * bookShadowMapTexelSize * 1.65;
shadow += bookShadowCompare(texture2D(bookShadowMaps[2], coord.xy + offset), coord.z);
}
}
return clamp(shadow / 9.0, 0.0, 1.0) * inBounds;
}
float bookMeshShadowField(vec3 point) {
float shadow0 = bookShadowSample0(bookShadowMatrices[0] * vec4(point, 1.0));
float shadow1 = bookShadowSample1(bookShadowMatrices[1] * vec4(point, 1.0));
float shadow2 = bookShadowSample2(bookShadowMatrices[2] * vec4(point, 1.0));
return clamp(max(max(shadow0, shadow1), shadow2) * 0.62, 0.0, 0.62);
}`
)
.replace(
@@ -702,24 +1022,23 @@ function configureTableShader(material) {
vec3 reflectedSurface = combinedReflection * (0.64 + fresnel * 0.36);
float reflectedLuma = dot(reflectedSurface, vec3(0.299, 0.587, 0.114));
reflectedSurface = mix(reflectedSurface, vec3(reflectedLuma), dustFilm * 0.42);
float contactAo = 1.0 - smoothstep(0.0, 0.9, length(vTableWorldPosition.xz * vec2(0.34, 0.58))) * 0.16;
float candleContact = candleContactField(vTableWorldPosition) * tableReflectionMask;
float candleProjectedShadow = candleProjectedShadowField(vTableWorldPosition) * tableReflectionMask;
float candleOcclusion = clamp(candleContact + candleProjectedShadow * 0.42, 0.0, 0.48);
float candleProjectedShadow = max(
max(candleProjectedShadowField(vTableWorldPosition), candlePlanarShadowField(vTableWorldPosition)),
bookMeshShadowField(vTableWorldPosition)
) * tableReflectionMask;
float candleOcclusion = clamp(candleProjectedShadow * 1.46, 0.0, 0.82);
vec3 normalDebug = normalize(normal) * 0.5 + 0.5;
outgoingLight *= mix(1.0, contactAo, tableReflectionMask * 0.55);
outgoingLight = mix(outgoingLight, reflectedSurface, tableReflectionMask * (0.16 + fresnel * 0.28 + greaseFilm * 0.065) * reflectionCleanliness);
outgoingLight += tableReflectionMask * roomReflection * 0.004 * reflectionCleanliness;
outgoingLight += tableReflectionMask * dustFilm * vec3(0.008, 0.0085, 0.009) * (0.22 + fresnel * 0.62);
outgoingLight += tableReflectionMask * greaseFilm * vec3(0.018, 0.013, 0.007) * (0.28 + fresnel * 0.58);
outgoingLight *= mix(vec3(1.0), vec3(0.5, 0.4, 0.31), candleOcclusion);
outgoingLight *= mix(vec3(1.0), vec3(0.19, 0.15, 0.115), candleOcclusion);
if (tableDebugMode == 1) outgoingLight = vec3(candleProjectedShadow);
if (tableDebugMode == 2) outgoingLight = vec3(dust);
if (tableDebugMode == 3) outgoingLight = normalDebug;
if (tableDebugMode == 4) outgoingLight = roomReflection;
if (tableDebugMode == 5) outgoingLight = sceneReflection;
if (tableDebugMode == 6) outgoingLight = vec3(tableReflectionMask);
if (tableDebugMode == 7) outgoingLight = vec3(candleContact);
if (tableDebugMode == 8) outgoingLight = vec3(grease);
#include <opaque_fragment>`
);
@@ -981,152 +1300,6 @@ function tintAmbientFromCanvas(canvas) {
candleBounceLight.intensity = 0.28;
}
function createTableNormalTexture() {
const canvas = document.createElement('canvas');
generatedTextureCanvases.tableNormal = canvas;
canvas.width = 2048;
canvas.height = 2048;
const ctx = canvas.getContext('2d');
const image = ctx.createImageData(canvas.width, canvas.height);
for (let y = 0; y < canvas.height; y += 1) {
for (let x = 0; x < canvas.width; x += 1) {
const i = (y * canvas.width + x) * 4;
const grain = Math.sin(x * 0.028) * 8 + Math.sin((x + y) * 0.011) * 5 + Math.sin(x * 0.11 + y * 0.007) * 2;
const pore = Math.sin(y * 0.12 + Math.sin(x * 0.016) * 2.1) * 3 + (Math.random() - 0.5) * 6;
image.data[i] = 128 + grain;
image.data[i + 1] = 128 + pore;
image.data[i + 2] = 255;
image.data[i + 3] = 255;
}
}
ctx.putImageData(image, 0, 0);
const texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.NoColorSpace;
texture.needsUpdate = true;
return texture;
}
function createTableDustTexture() {
const canvas = document.createElement('canvas');
generatedTextureCanvases.tableDust = canvas;
canvas.width = 4096;
canvas.height = 4096;
const ctx = canvas.getContext('2d');
ctx.fillStyle = 'rgb(1, 1, 1)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
const image = ctx.getImageData(0, 0, canvas.width, canvas.height);
for (let y = 0; y < canvas.height; y += 1) {
for (let x = 0; x < canvas.width; x += 1) {
const i = (y * canvas.width + x) * 4;
const nx = x / canvas.width;
const ny = y / canvas.height;
const edgeDust = Math.max(0, 1 - Math.min(nx, ny, 1 - nx, 1 - ny) * 18);
const microNoise = Math.pow(Math.random(), 7.2) * 5.5;
const fineFilm = Math.max(0, Math.sin(nx * 21 + Math.sin(ny * 11) * 0.7) - 0.988) * 3;
const bookShelter = Math.exp(-Math.pow((nx - 0.5) * 2.7, 2) - Math.pow((ny - 0.5) * 1.8, 2)) * 1.5;
const value = Math.min(255, 1 + edgeDust * 3 + microNoise + fineFilm + bookShelter);
image.data[i] = value;
image.data[i + 1] = value;
image.data[i + 2] = value;
image.data[i + 3] = 255;
}
}
ctx.putImageData(image, 0, 0);
ctx.globalCompositeOperation = 'screen';
ctx.fillStyle = 'rgba(230, 230, 230, 0.028)';
for (let i = 0; i < 1700; i += 1) {
const x = Math.random() * canvas.width;
const y = Math.random() * canvas.height;
const radius = 0.08 + Math.random() * 0.22;
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalCompositeOperation = 'source-over';
const texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.NoColorSpace;
texture.minFilter = THREE.LinearMipmapLinearFilter;
texture.magFilter = THREE.LinearFilter;
texture.generateMipmaps = true;
texture.needsUpdate = true;
return texture;
}
function createTableGreaseTexture() {
const canvas = document.createElement('canvas');
generatedTextureCanvases.tableGrease = canvas;
canvas.width = 4096;
canvas.height = 4096;
const ctx = canvas.getContext('2d');
ctx.fillStyle = 'rgb(0, 0, 0)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
const smudge = (x, y, rx, ry, alpha) => {
const gradient = ctx.createRadialGradient(x, y, 0, x, y, Math.max(rx, ry));
gradient.addColorStop(0, `rgba(220, 220, 220, ${alpha})`);
gradient.addColorStop(0.42, `rgba(150, 150, 150, ${alpha * 0.32})`);
gradient.addColorStop(1, 'rgba(0, 0, 0, 0)');
ctx.save();
ctx.translate(x, y);
ctx.scale(rx / Math.max(rx, ry), ry / Math.max(rx, ry));
ctx.fillStyle = gradient;
ctx.beginPath();
ctx.arc(0, 0, Math.max(rx, ry), 0, Math.PI * 2);
ctx.fill();
ctx.restore();
};
smudge(canvas.width * 0.17, canvas.height * 0.38, 210, 88, 0.088);
smudge(canvas.width * 0.83, canvas.height * 0.24, 170, 76, 0.081);
smudge(canvas.width * 0.73, canvas.height * 0.76, 185, 78, 0.072);
smudge(canvas.width * 0.5, canvas.height * 0.52, 300, 120, 0.053);
const fingerprint = (x, y, rx, ry, rotation, alpha) => {
ctx.save();
ctx.translate(x, y);
ctx.rotate(rotation);
const gradient = ctx.createRadialGradient(0, 0, 0, 0, 0, Math.max(rx, ry));
gradient.addColorStop(0, `rgba(235, 235, 235, ${alpha})`);
gradient.addColorStop(0.68, `rgba(200, 200, 200, ${alpha * 0.48})`);
gradient.addColorStop(1, 'rgba(0, 0, 0, 0)');
ctx.fillStyle = gradient;
ctx.beginPath();
ctx.ellipse(0, 0, rx, ry, 0, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.ellipse(0, 0, rx, ry, 0, 0, Math.PI * 2);
ctx.clip();
ctx.strokeStyle = `rgba(245, 245, 245, ${alpha * 0.42})`;
ctx.lineWidth = 2.4;
for (let ridge = -0.7; ridge <= 0.7; ridge += 0.18) {
ctx.beginPath();
ctx.ellipse(rx * ridge * 0.18, ry * ridge * 0.24, rx * (0.26 + Math.abs(ridge) * 0.58), ry * (0.2 + Math.abs(ridge) * 0.5), 0, Math.PI * 0.08, Math.PI * 1.92);
ctx.stroke();
}
ctx.restore();
};
ctx.globalCompositeOperation = 'screen';
for (let i = 0; i < 8; i += 1) {
const x = canvas.width * (0.2 + Math.random() * 0.62);
const y = canvas.height * (0.18 + Math.random() * 0.64);
fingerprint(x, y, 36 + Math.random() * 22, 14 + Math.random() * 8, Math.random() * Math.PI, 0.102 + Math.random() * 0.042);
}
ctx.globalCompositeOperation = 'source-over';
const texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.NoColorSpace;
texture.minFilter = THREE.LinearMipmapLinearFilter;
texture.magFilter = THREE.LinearFilter;
texture.generateMipmaps = true;
texture.needsUpdate = true;
return texture;
}
function drawCentered(ctx, text, y, size) {
ctx.font = `${size}px Georgia, "Times New Roman", serif`;
ctx.textAlign = 'center';
@@ -1155,6 +1328,8 @@ function resize() {
const width = Math.max(1, window.innerWidth);
const height = Math.max(1, window.innerHeight);
renderer.setSize(width, height, false);
if (composer) composer.setSize(width, height);
if (sceneAoPass) sceneAoPass.setSize(width, height);
camera.aspect = width / height;
camera.updateProjectionMatrix();
const desiredReflectionScale = reflectionPixelRatio * 1.5;
@@ -1278,6 +1453,61 @@ function updateCandleShadowUniforms() {
});
}
function updateBookShadowMaps() {
if (!tableShader || candleShadowSources.length < 3) return;
const previousRenderTarget = renderer.getRenderTarget();
const previousXrEnabled = renderer.xr.enabled;
const previousShadowAutoUpdate = renderer.shadowMap.autoUpdate;
const previousToneMappingExposure = renderer.toneMappingExposure;
const previousOverrideMaterial = scene.overrideMaterial;
const previousClearColor = new THREE.Color();
renderer.getClearColor(previousClearColor);
const previousClearAlpha = renderer.getClearAlpha();
const hiddenObjects = [tableMesh, ...candleShadowSources].filter(Boolean);
hiddenObjects.forEach((object) => {
object.userData.wasVisibleForBookShadow = object.visible;
object.visible = false;
});
renderer.xr.enabled = false;
renderer.shadowMap.autoUpdate = false;
renderer.toneMappingExposure = 1;
renderer.setClearColor(0xffffff, 1);
scene.overrideMaterial = bookShadowDepthMaterial;
candleShadowSources.forEach((candle, index) => {
candle.userData.flame.getWorldPosition(flameWorldPosition);
const shadowCamera = bookShadowCameras[index];
shadowCamera.position.copy(flameWorldPosition);
shadowCamera.lookAt(0, 0.09, 0);
shadowCamera.updateProjectionMatrix();
shadowCamera.updateMatrixWorld();
shadowCamera.matrixWorldInverse.copy(shadowCamera.matrixWorld).invert();
bookShadowMatrices[index]
.copy(bookShadowBiasMatrix)
.multiply(shadowCamera.projectionMatrix)
.multiply(shadowCamera.matrixWorldInverse);
renderer.setRenderTarget(bookShadowTargets[index]);
renderer.clear();
renderer.render(scene, shadowCamera);
});
scene.overrideMaterial = previousOverrideMaterial;
hiddenObjects.forEach((object) => {
object.visible = object.userData.wasVisibleForBookShadow;
delete object.userData.wasVisibleForBookShadow;
});
renderer.setClearColor(previousClearColor, previousClearAlpha);
renderer.toneMappingExposure = previousToneMappingExposure;
renderer.shadowMap.autoUpdate = previousShadowAutoUpdate;
renderer.xr.enabled = previousXrEnabled;
renderer.setRenderTarget(previousRenderTarget);
}
function updateTableReflection() {
if (!tableMesh || !tableShader) return;
@@ -1354,6 +1584,13 @@ function animate() {
}
});
updateCandleShadowUniforms();
updateBookShadowMaps();
updateTableReflection();
if (composer) {
composer.render();
} else {
renderer.render(scene, camera);
}
window.BookLabDebug.renderedFrames += 1;
window.BookLabDebug.ready = true;
}
+40
View File
@@ -0,0 +1,40 @@
const fs = require('fs');
const path = require('path');
const sourcePath = path.join(__dirname, '..', 'public', 'js', 'webgl-book-lab.js');
const source = fs.readFileSync(sourcePath, 'utf8');
const checks = [
['scene-level SSAO import', /SSAOPass/.test(source)],
['postprocess anti-aliasing import', /SMAAPass/.test(source)],
['composer uses explicit render target', /new THREE\.WebGLRenderTarget\(1, 1/.test(source) && /new EffectComposer\(renderer, sceneComposerTarget\)/.test(source)],
['composer render path is active', /composer\.render\(\)/.test(source)],
['static table maps are loaded from disk', /table_normal_2k\.png/.test(source) && /table_dust_4k\.png/.test(source) && /table_grease_4k\.png/.test(source)],
['runtime table map generators removed from page', !/function createTableNormalTexture|function createTableDustTexture|function createTableGreaseTexture/.test(source)],
['table primitive shadow receiving disabled', /tableMesh\.receiveShadow = false/.test(source)],
['flames excluded from AO', /excludeFromAo = true/.test(source) && /aoExcludedObjects\.push\(child\)/.test(source)],
['AO pass hides excluded objects with cleanup', /sceneAoPass\.render = \(\.\.\.args\) =>/.test(source) && /finally/.test(source)],
['AO uses scene-scale sampling', /new SSAOPass\(scene, camera, 1, 1, 64\)/.test(source) && /sceneAoPass\.kernelRadius = 0\.48/.test(source) && /sceneAoPass\.minDistance = 0\.00025/.test(source) && /sceneAoPass\.maxDistance = 0\.065/.test(source)],
['AO debug shows blurred occlusion map', /tableDebugName === 'ao' && SSAOPass\.OUTPUT\?\.Blur/.test(source) && /sceneAoPass\.output = SSAOPass\.OUTPUT\.Blur/.test(source)],
['direct candle shadow lobe present', /candlePlanarShadowLobe/.test(source) && /candlePlanarShadowField/.test(source)],
['direct candle shadow contributes to final table shader', /max\(candleProjectedShadowField\(vTableWorldPosition\), candlePlanarShadowField\(vTableWorldPosition\)\)/.test(source) && /bookMeshShadowField\(vTableWorldPosition\)/.test(source)],
['book shadows use real light-space depth maps', /bookShadowTargets/.test(source) && /MeshDepthMaterial/.test(source) && /updateBookShadowMaps/.test(source) && /bookMeshShadowField/.test(source) && /bookShadowMaps\[0\]/.test(source)],
['book materials receive real shadow maps', /configureBookShadowReceiver\(materials\.leftPage/.test(source) && /bookReceiverShadowField/.test(source) && /bookShadowReceiverStrength/.test(source)],
['proxy book shadow shortcuts are forbidden', !/bookPlanarShadowLobe|bookProjectedShadowField|bookBoxShadow|segmentBoxHit/.test(source)],
['final candle shadow is visible in composite', /candleOcclusion = clamp\(candleProjectedShadow \* 1\.46, 0\.0, 0\.82\)/.test(source) && /vec3\(0\.19, 0\.15, 0\.115\), candleOcclusion/.test(source)],
['primitive candle shadow shortcuts stay disabled', /wax\.castShadow = false/.test(source) && /wick\.castShadow = false/.test(source) && /leftPage\.receiveShadow = false/.test(source) && /rightPage\.receiveShadow = false/.test(source)],
['analytic contact fallback removed', !/surfaceContactOcclusion|candleContactField|candleContactOcclusion|bookContactField|candleFootOcclusion|contactAo/.test(source)],
['debug AO remains scene-level', /scene debug: SSAO/.test(source)],
['contact debug mode removed', !/contact:\s*9|tableDebugMode == 9/.test(source)],
['render readiness flag is exposed', /BookLabDebug\.ready/.test(source) && /BookLabDebug\.renderedFrames/.test(source)]
];
const failures = checks.filter(([, passed]) => !passed).map(([name]) => name);
if (failures.length) {
console.error('WebGL book lab regression checks failed:');
failures.forEach((name) => console.error(`- ${name}`));
process.exit(1);
}
console.log(`WebGL book lab regression checks passed (${checks.length}).`);
+105
View File
@@ -0,0 +1,105 @@
from __future__ import annotations
import math
import random
from pathlib import Path
from PIL import Image, ImageDraw
ROOT = Path(__file__).resolve().parents[1]
ASSET_DIR = ROOT / "public" / "assets" / "webgl"
RNG = random.Random(1780504860)
def save_normal(path: Path, size: int = 2048) -> None:
image = Image.new("RGB", (size, size))
pixels = image.load()
for y in range(size):
for x in range(size):
grain = (
math.sin(x * 0.028) * 8
+ math.sin((x + y) * 0.011) * 5
+ math.sin(x * 0.11 + y * 0.007) * 2
)
pore = math.sin(y * 0.12 + math.sin(x * 0.016) * 2.1) * 3 + (RNG.random() - 0.5) * 6
pixels[x, y] = (
max(0, min(255, round(128 + grain))),
max(0, min(255, round(128 + pore))),
255,
)
image.save(path)
def save_dust(path: Path, size: int = 4096) -> None:
image = Image.new("L", (size, size), 1)
pixels = image.load()
for y in range(size):
ny = y / size
for x in range(size):
nx = x / size
edge_dust = max(0, 1 - min(nx, ny, 1 - nx, 1 - ny) * 18)
micro_noise = (RNG.random() ** 7.2) * 5.5
fine_film = max(0, math.sin(nx * 21 + math.sin(ny * 11) * 0.7) - 0.988) * 3
book_shelter = math.exp(-((nx - 0.5) * 2.7) ** 2 - ((ny - 0.5) * 1.8) ** 2) * 1.5
value = min(255, 1 + edge_dust * 3 + micro_noise + fine_film + book_shelter)
pixels[x, y] = round(value)
draw = ImageDraw.Draw(image, "L")
for _ in range(1700):
x = RNG.random() * size
y = RNG.random() * size
radius = 0.08 + RNG.random() * 0.22
value = round(230 * 0.028)
draw.ellipse((x - radius, y - radius, x + radius, y + radius), fill=value)
image.save(path)
def soft_ellipse(draw: ImageDraw.ImageDraw, x: float, y: float, rx: float, ry: float, alpha: float) -> None:
steps = 36
for step in range(steps, 0, -1):
scale = step / steps
value = round(220 * alpha * (scale ** 2.1))
draw.ellipse((x - rx * scale, y - ry * scale, x + rx * scale, y + ry * scale), fill=value)
def save_grease(path: Path, size: int = 4096) -> None:
image = Image.new("L", (size, size), 0)
draw = ImageDraw.Draw(image, "L")
for x, y, rx, ry, alpha in [
(0.17, 0.38, 210, 88, 0.088),
(0.83, 0.24, 170, 76, 0.081),
(0.73, 0.76, 185, 78, 0.072),
(0.5, 0.52, 300, 120, 0.053),
]:
soft_ellipse(draw, size * x, size * y, rx, ry, alpha)
for _ in range(8):
x = size * (0.2 + RNG.random() * 0.62)
y = size * (0.18 + RNG.random() * 0.64)
rx = 36 + RNG.random() * 22
ry = 14 + RNG.random() * 8
alpha = 0.102 + RNG.random() * 0.042
soft_ellipse(draw, x, y, rx, ry, alpha)
for ridge in [i / 100 for i in range(-70, 71, 18)]:
bbox = (
x - rx * (0.26 + abs(ridge) * 0.58),
y - ry * (0.2 + abs(ridge) * 0.5),
x + rx * (0.26 + abs(ridge) * 0.58),
y + ry * (0.2 + abs(ridge) * 0.5),
)
draw.arc(bbox, 15, 345, fill=round(245 * alpha * 0.42), width=2)
image.save(path)
def main() -> None:
ASSET_DIR.mkdir(parents=True, exist_ok=True)
save_normal(ASSET_DIR / "table_normal_2k.png")
save_dust(ASSET_DIR / "table_dust_4k.png")
save_grease(ASSET_DIR / "table_grease_4k.png")
if __name__ == "__main__":
main()