Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion index.html
Original file line number Diff line number Diff line change
Expand Up @@ -95,9 +95,9 @@
<div class="control-item">
<label for="lighting-select">Lighting</label>
<select id="lighting-select" class="styled-select">
<option value="studio-neutral" selected>Studio Neutral</option>
<option value="trailer-golden-hour">Trailer Golden Hour</option>
<option value="noon-clear">Noon Clear</option>
<option value="studio-neutral">Studio Neutral</option>
</select>
</div>

Expand Down
6 changes: 4 additions & 2 deletions src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import { preloadStandardColormaps, getBiomeTint, getBiomeData } from "./modules/biome-engine.js";
import { BIOME_PRESETS, getBiomeSwatch, formatBiomeCoordinates, shouldApplyGrassTint, shouldCompositeSideOverlay, compositeGrassSideBuffers } from "./modules/biome-ui.js";
import { LIGHTING_PRESETS, createLightingRig, applyLightingPreset } from "./modules/lighting-presets.js";
import { LIGHTING_PRESETS, createLightingRig, applyLightingPreset, DEFAULT_LIGHTING_PRESET } from "./modules/lighting-presets.js";
import { applyPOM, clampDepthScale, POM_DEFAULT_DEPTH_SCALE } from "./modules/pbr-pom.js";
import { createLabPBRMaterial, applyLabPBRShader } from "./modules/pbr-material.js";
import { isPlantOrCrossBlock, createCrossQuadGeometry, cullMultiblockFaces } from "./modules/block-culling.js";
Expand Down Expand Up @@ -137,7 +137,7 @@ class TextureStudioApp {
this.viewports = [];

this.currentBiome = "plains";
this.currentLightingPreset = "trailer-golden-hour";
this.currentLightingPreset = DEFAULT_LIGHTING_PRESET || "studio-neutral";
this.isPomEnabled = true;
this.pomDepthScale = POM_DEFAULT_DEPTH_SCALE;

Expand Down Expand Up @@ -397,6 +397,8 @@ class TextureStudioApp {
const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: "high-performance" });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.0;
this.dom.canvasWrappers[i].appendChild(renderer.domElement);

const controls = new OrbitControls(camera, renderer.domElement);
Expand Down
97 changes: 64 additions & 33 deletions src/modules/lighting-presets.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ export const LIGHTING_PRESETS = Object.freeze({
color: "#ffb366",
colorHex: 0xffb366,
hex: "#ffb366",
intensity: 1.8,
intensity: 1.2,
azimuth: 55,
elevation: 28,
distance: 50,
Expand All @@ -75,7 +75,7 @@ export const LIGHTING_PRESETS = Object.freeze({
color: "#8ca0ba",
colorHex: 0x8ca0ba,
hex: "#8ca0ba",
intensity: 0.6
intensity: 0.4
}),
fill: null,
rim: null,
Expand Down Expand Up @@ -110,12 +110,12 @@ export const LIGHTING_PRESETS = Object.freeze({
"studio-neutral": Object.freeze({
id: "studio-neutral",
name: "Studio Neutral",
description: "Multi-angle diffuse 3-point lighting setup with key, fill, rim/bounce, and ambient illumination.",
description: "Multi-angle diffuse 3-point lighting setup with neutral key, fill, bounce, and ambient illumination calibrated for accurate texture inspection.",
sun: Object.freeze({
color: "#fffaf0",
colorHex: 0xfffaf0,
hex: "#fffaf0",
intensity: 1.5,
color: "#ffffff",
colorHex: 0xffffff,
hex: "#ffffff",
intensity: 1.0,
azimuth: 35.54,
elevation: 49.3,
distance: 13.19,
Expand All @@ -126,46 +126,69 @@ export const LIGHTING_PRESETS = Object.freeze({
color: "#ffffff",
colorHex: 0xffffff,
hex: "#ffffff",
intensity: 0.5
intensity: 0.4
}),
fill: Object.freeze({
color: "#7da4c7",
colorHex: 0x7da4c7,
hex: "#7da4c7",
intensity: 0.5,
color: "#ffffff",
colorHex: 0xffffff,
hex: "#ffffff",
intensity: 0.3,
position: Object.freeze([-5, 2, -5])
}),
rim: Object.freeze({
color: "#ffeedd",
colorHex: 0xffeedd,
hex: "#ffeedd",
intensity: 0.4,
color: "#ffffff",
colorHex: 0xffffff,
hex: "#ffffff",
intensity: 0.2,
position: Object.freeze([0, -8, 0])
}),
extraLights: Object.freeze([
Object.freeze({
id: "fill",
type: "directional",
color: "#7da4c7",
colorHex: 0x7da4c7,
hex: "#7da4c7",
intensity: 0.5,
color: "#ffffff",
colorHex: 0xffffff,
hex: "#ffffff",
intensity: 0.3,
position: Object.freeze([-5, 2, -5])
}),
Object.freeze({
id: "rim",
type: "directional",
color: "#ffeedd",
colorHex: 0xffeedd,
hex: "#ffeedd",
intensity: 0.4,
color: "#ffffff",
colorHex: 0xffffff,
hex: "#ffffff",
intensity: 0.2,
position: Object.freeze([0, -8, 0])
})
])
})
});

export const DEFAULT_LIGHTING_PRESET = "trailer-golden-hour";
export const DEFAULT_LIGHTING_PRESET = "studio-neutral";

export const DEFAULT_TONE_MAPPING = THREE_DEFAULT.ACESFilmicToneMapping;
export const DEFAULT_TONE_MAPPING_EXPOSURE = 1.0;

/**
* Configures ACES Filmic or AgX tone mapping and exposure on a Three.js WebGLRenderer.
*
* @param {object} renderer - WebGLRenderer instance
* @param {object} [options={}] - Options (toneMapping, exposure, THREE)
* @returns {object} The configured renderer
*/
export function configureToneMapping(renderer, options = {}) {
if (!renderer) {
throw new TypeError("configureToneMapping requires a valid WebGLRenderer instance");
}
const THREE = options?.THREE || (typeof globalThis !== "undefined" && globalThis.THREE) || THREE_DEFAULT;
const toneMapping = options.toneMapping ?? THREE.ACESFilmicToneMapping;
const exposure = typeof options.exposure === "number" ? options.exposure : (options.toneMappingExposure ?? DEFAULT_TONE_MAPPING_EXPOSURE);

renderer.toneMapping = toneMapping;
renderer.toneMappingExposure = exposure;
return renderer;
}

/**
* Internal registry lookup map including aliases.
Expand Down Expand Up @@ -496,8 +519,8 @@ export class LightingRig {
this.group = new THREE.Group();
this.group.name = options.name || "LightingRig";

// Primary directional sunlight
this.sunLight = new THREE.DirectionalLight(0xffb366, 1.8);
// Primary directional sunlight / key light
this.sunLight = new THREE.DirectionalLight(0xffffff, 1.0);
this.sunLight.name = "SunLight";

this.sunTarget = new THREE.Object3D();
Expand All @@ -516,15 +539,15 @@ export class LightingRig {
this.sunLight.shadow.bias = options.shadowBias || -0.0005;

// Ambient light
this.ambientLight = new THREE.AmbientLight(0x8ca0ba, 0.6);
this.ambientLight = new THREE.AmbientLight(0xffffff, 0.4);
this.ambientLight.name = "AmbientLight";

// Secondary lights (used in 3-point lighting presets like Studio Neutral)
this.fillLight = new THREE.DirectionalLight(0x7da4c7, 0.0);
this.fillLight = new THREE.DirectionalLight(0xffffff, 0.0);
this.fillLight.name = "FillLight";
this.fillLight.visible = false;

this.rimLight = new THREE.DirectionalLight(0xffeedd, 0.0);
this.rimLight = new THREE.DirectionalLight(0xffffff, 0.0);
this.rimLight.name = "RimLight";
this.rimLight.visible = false;

Expand All @@ -538,14 +561,18 @@ export class LightingRig {
this.scene.add(this.group);
}

this.azimuth = 55;
this.elevation = 28;
this.distance = 50;
this.azimuth = 35.54;
this.elevation = 49.3;
this.distance = 13.19;
this.currentPresetId = null;
this.currentPreset = null;

const initialPreset = options.preset || DEFAULT_LIGHTING_PRESET;
this.setPreset(initialPreset);

if (options.renderer) {
configureToneMapping(options.renderer, options);
}
}

/**
Expand Down Expand Up @@ -798,6 +825,10 @@ export function applyLightingPreset(target, presetIdOrConfig, options = {}) {
throw new TypeError("applyLightingPreset requires a valid target (LightingRig, THREE.Scene, or lighting object)");
}

if (options.renderer) {
configureToneMapping(options.renderer, options);
}

if (target instanceof LightingRig || typeof target.setPreset === "function") {
return target.setPreset(presetIdOrConfig);
}
Expand Down
96 changes: 94 additions & 2 deletions tools/test-e2e.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,15 @@ import {
invalidateTextureCache
} from "../src/modules/live-sync.js";

import {
LIGHTING_PRESETS,
DEFAULT_LIGHTING_PRESET,
DEFAULT_TONE_MAPPING,
DEFAULT_TONE_MAPPING_EXPOSURE,
configureToneMapping,
createLightingRig
} from "../src/modules/lighting-presets.js";

/**
* Deterministic Mock EventSource for client-side SSE verification in Node.js.
*/
Expand Down Expand Up @@ -126,7 +135,8 @@ const verificationResults = {
req1_grassBiomeTintMatch: false,
req2_labPbrColorSpaceIntegrity: false,
req3_crossQuadMultiblockCulling: false,
req4_liveSyncTouchReload: false
req4_liveSyncTouchReload: false,
req5_studioInspectionLightingToneMapping: false
};

const EPSILON = 1e-4;
Expand Down Expand Up @@ -787,6 +797,85 @@ test("Requirement 4: Live file reload triggers seamlessly on SVG touch", () => {
verificationResults.req4_liveSyncTouchReload = true;
});

// ============================================================================
// REQUIREMENT 5: Studio Inspection Lighting Calibration & ACES Tone Mapping
// ============================================================================
test("Requirement 5: Studio inspection lighting and ACES tone mapping prevent color blowout", async () => {
// 1. Default preset must be studio-neutral
assert.equal(
DEFAULT_LIGHTING_PRESET,
"studio-neutral",
"Default lighting preset must be studio-neutral for accurate inspection"
);

// 2. Studio Neutral preset inspection calibration
const studioPreset = LIGHTING_PRESETS["studio-neutral"];
assert.ok(studioPreset, "studio-neutral preset must exist");
assert.equal(studioPreset.sun.hex, "#ffffff", "Studio Neutral key light must be pure neutral white");
assert.equal(studioPreset.ambient.hex, "#ffffff", "Studio Neutral ambient light must be pure neutral white");
assert.equal(studioPreset.fill.hex, "#ffffff", "Studio Neutral fill light must be pure neutral white");
assert.equal(studioPreset.rim.hex, "#ffffff", "Studio Neutral rim light must be pure neutral white");

// Verify balanced intensities across 3-point inspection rig (combined illumination <= 1.5)
assertClose(studioPreset.sun.intensity, 1.0, "Key light intensity must be 1.0");
assertClose(studioPreset.ambient.intensity, 0.4, "Ambient intensity must be 0.4");
assertClose(studioPreset.fill.intensity, 0.3, "Fill intensity must be 0.3");
assertClose(studioPreset.rim.intensity, 0.2, "Rim intensity must be 0.2");
const studioTotal = studioPreset.sun.intensity + studioPreset.ambient.intensity;
assert.ok(
studioTotal <= 1.5,
`Studio Neutral direct + ambient (${studioTotal}) must prevent highlight blowout (<= 1.5)`
);

// 3. Trailer Golden Hour calibrated intensities (softened to prevent color burnout)
const goldenPreset = LIGHTING_PRESETS["trailer-golden-hour"];
assert.ok(goldenPreset, "trailer-golden-hour preset must exist");
assertClose(goldenPreset.sun.intensity, 1.2, "Golden Hour sun intensity must be calibrated to 1.2");
assertClose(goldenPreset.ambient.intensity, 0.4, "Golden Hour ambient intensity must be calibrated to 0.4");
const goldenTotal = goldenPreset.sun.intensity + goldenPreset.ambient.intensity;
assert.ok(
goldenTotal <= 1.7,
`Trailer Golden Hour combined illumination (${goldenTotal}) must not exceed 1.7 (softened from 2.4)`
);

// 4. Tone mapping and exposure configuration on renderer
const mockRenderer = {
toneMapping: THREE.NoToneMapping,
toneMappingExposure: 0.5
};
configureToneMapping(mockRenderer);
assert.equal(
mockRenderer.toneMapping,
THREE.ACESFilmicToneMapping,
"Renderer must be configured with ACESFilmicToneMapping"
);
assertClose(
mockRenderer.toneMappingExposure,
1.0,
"Renderer toneMappingExposure must be set to 1.0"
);

// 5. LightingRig initialization defaults to studio-neutral and auto-configures renderer
const scene = new THREE.Scene();
const rigRenderer = {
toneMapping: THREE.NoToneMapping,
toneMappingExposure: 0.5
};
const rig = createLightingRig(scene, { renderer: rigRenderer });
assert.equal(rig.currentPresetId, "studio-neutral", "LightingRig must default to studio-neutral");
assert.equal(rigRenderer.toneMapping, THREE.ACESFilmicToneMapping, "Rig must configure tone mapping on renderer");
assertClose(rigRenderer.toneMappingExposure, 1.0, "Rig must configure exposure 1.0 on renderer");

// Verify pure neutral light colors applied to scene
assert.equal(rig.sunLight.color.getHexString(), "ffffff");
assert.equal(rig.ambientLight.color.getHexString(), "ffffff");
assert.equal(rig.fillLight.color.getHexString(), "ffffff");
assert.equal(rig.rimLight.color.getHexString(), "ffffff");
rig.dispose();

verificationResults.req5_studioInspectionLightingToneMapping = true;
});

// ============================================================================
// SUITE SUMMARY REPORTING
// ============================================================================
Expand All @@ -795,12 +884,14 @@ after(() => {
const req2Status = verificationResults.req2_labPbrColorSpaceIntegrity ? "PASSED" : "FAILED";
const req3Status = verificationResults.req3_crossQuadMultiblockCulling ? "PASSED" : "FAILED";
const req4Status = verificationResults.req4_liveSyncTouchReload ? "PASSED" : "FAILED";
const req5Status = verificationResults.req5_studioInspectionLightingToneMapping ? "PASSED" : "FAILED";

const allPassed =
verificationResults.req1_grassBiomeTintMatch &&
verificationResults.req2_labPbrColorSpaceIntegrity &&
verificationResults.req3_crossQuadMultiblockCulling &&
verificationResults.req4_liveSyncTouchReload;
verificationResults.req4_liveSyncTouchReload &&
verificationResults.req5_studioInspectionLightingToneMapping;

console.log("\n================================================================================");
console.log(" TEXTURESTUDIO END-TO-END VISUAL VERIFICATION SUITE ");
Expand All @@ -809,6 +900,7 @@ after(() => {
console.log(` [REQ 2] LabPBR Normals, Specular, AO & Color Space Integrity : ${req2Status}`);
console.log(` [REQ 3] Cross-Quad Plant Geometry & Multiblock Culling (No Z-Fight): ${req3Status}`);
console.log(` [REQ 4] Live Sync SSE Invalidation & Cache-Bust Dispatch : ${req4Status}`);
console.log(` [REQ 5] Studio Inspection Lighting Calibration & ACES Tone Mapping : ${req5Status}`);
console.log("================================================================================");
if (allPassed) {
console.log(" OVERALL VERIFICATION VERDICT: ALL E2E REQUIREMENTS VERIFIED SUCCESSFULLY ");
Expand Down
Loading
Loading