diff --git a/examples/jsm/inspector/Extension.js b/examples/jsm/inspector/Extension.js
index 18e3507fc5ecf1..beee0191248c51 100644
--- a/examples/jsm/inspector/Extension.js
+++ b/examples/jsm/inspector/Extension.js
@@ -1,4 +1,5 @@
import { Tab } from 'three/addons/inspector/ui/Tab.js';
+import { getItem, setItem } from './Inspector.js';
export class Extension extends Tab {
@@ -10,4 +11,97 @@ export class Extension extends Tab {
}
+ init( inspector ) {
+
+ super.init( inspector );
+
+ if ( this._handleLayout && this._inspector ) {
+
+ this._inspector.removeEventListener( 'orientationchange', this._handleLayout );
+ this._inspector.removeEventListener( 'layoutchange', this._handleLayout );
+ this._inspector.removeEventListener( 'resize', this._handleLayout );
+ window.removeEventListener( 'resize', this._handleLayout );
+
+ }
+
+ this._inspector = inspector;
+
+ this._handleLayout = ( event ) => {
+
+ this.onOrientationChange( event );
+ this.onLayoutChange( event );
+
+ };
+
+ if ( inspector ) {
+
+ inspector.addEventListener( 'orientationchange', this._handleLayout );
+ inspector.addEventListener( 'layoutchange', this._handleLayout );
+ inspector.addEventListener( 'resize', this._handleLayout );
+
+ }
+
+ window.addEventListener( 'resize', this._handleLayout );
+
+ const data = getItem( this.name );
+
+ if ( Object.keys( data ).length > 0 ) {
+
+ this.deserialize( data );
+
+ }
+
+ }
+
+ serialize() {
+
+ return {};
+
+ }
+
+ deserialize( /* data */ ) {
+
+ }
+
+ save() {
+
+ const data = this.serialize();
+
+ if ( data === null || ( typeof data === 'object' && Object.keys( data ).length === 0 ) ) {
+
+ setItem( this.name, null );
+
+ } else {
+
+ setItem( this.name, data );
+
+ }
+
+ }
+
+ dispose() {
+
+ if ( this._handleLayout ) {
+
+ if ( this._inspector ) {
+
+ this._inspector.removeEventListener( 'orientationchange', this._handleLayout );
+ this._inspector.removeEventListener( 'layoutchange', this._handleLayout );
+ this._inspector.removeEventListener( 'resize', this._handleLayout );
+
+ }
+
+ window.removeEventListener( 'resize', this._handleLayout );
+ this._handleLayout = null;
+
+ }
+
+ this._inspector = null;
+
+ }
+
+ onOrientationChange( /* event */ ) { }
+
+ onLayoutChange( /* event */ ) { }
+
}
diff --git a/examples/jsm/inspector/Inspector.js b/examples/jsm/inspector/Inspector.js
index 4ae0b318e4eed2..1e24605e794bf3 100644
--- a/examples/jsm/inspector/Inspector.js
+++ b/examples/jsm/inspector/Inspector.js
@@ -22,6 +22,8 @@ class Inspector extends RendererInspector {
const profiler = new Profiler( this );
profiler.addEventListener( 'resize', ( e ) => this.dispatchEvent( e ) );
+ profiler.addEventListener( 'orientationchange', ( e ) => this.dispatchEvent( e ) );
+ profiler.addEventListener( 'layoutchange', ( e ) => this.dispatchEvent( e ) );
const parameters = new Parameters( {
builtin: true,
@@ -95,6 +97,12 @@ class Inspector extends RendererInspector {
}
+ isVertical() {
+
+ return this.profiler ? this.profiler.isVertical() : false;
+
+ }
+
onExtension( name, callback ) {
const extensionAdded = ( e ) => {
@@ -189,6 +197,8 @@ class Inspector extends RendererInspector {
removeTab( tab ) {
+ tab.dispose();
+
this.profiler.removeTab( tab );
return this;
diff --git a/examples/jsm/inspector/extensions/color-grading/ColorGrading.js b/examples/jsm/inspector/extensions/color-grading/ColorGrading.js
new file mode 100644
index 00000000000000..e6c89c856bed78
--- /dev/null
+++ b/examples/jsm/inspector/extensions/color-grading/ColorGrading.js
@@ -0,0 +1,2615 @@
+import { Extension } from 'three/addons/inspector/Extension.js';
+import {
+ createDefaultParams,
+ LUT_PRESETS,
+ exportCubeFormat,
+ exportLUTCanvas,
+ parseCubeFormat
+} from './LUTMath.js';
+import {
+ Data3DTexture,
+ LinearFilter,
+ RGBAFormat,
+ FloatType,
+ NoToneMapping,
+ RenderPipeline
+} from 'three/webgpu';
+import { pass, texture3D, renderOutput } from 'three/tsl';
+import { lut3D } from 'three/addons/tsl/display/Lut3DNode.js';
+
+import { LUT3DStyle } from './LUT3DStyle.js';
+
+// Module imports
+import { WhiteBalanceModule } from './modules/WhiteBalanceModule.js';
+import { ExposureModule } from './modules/ExposureModule.js';
+import { BrightnessModule } from './modules/BrightnessModule.js';
+import { HueModule } from './modules/HueModule.js';
+import { ColorWheelModule } from './modules/ColorWheelModule.js';
+import { CurvesModule } from './modules/CurvesModule.js';
+import { ContrastModule } from './modules/ContrastModule.js';
+import { SaturationModule } from './modules/SaturationModule.js';
+import { VibranceModule } from './modules/VibranceModule.js';
+import { ImportedCubeModule } from './modules/ImportedCubeModule.js';
+import { RendererModule } from './modules/RendererModule.js';
+import { OutputModule } from './modules/OutputModule.js';
+
+const _rendererPipelines = new WeakMap();
+
+const _origRender = RenderPipeline.prototype.render;
+
+if ( _origRender && ! _origRender._lut3dHooked ) {
+
+ RenderPipeline.prototype.render = function ( ...args ) {
+
+ if ( this.renderer ) {
+
+ _rendererPipelines.set( this.renderer, this );
+
+ }
+
+ return _origRender.apply( this, args );
+
+ };
+
+ RenderPipeline.prototype.render._lut3dHooked = true;
+
+}
+
+const DEFAULT_PIPELINE_ORDER = [
+ 'renderer',
+ 'whiteBalance',
+ 'lift',
+ 'gammaBal',
+ 'gain',
+ 'offset',
+ 'curves',
+ 'saturation',
+ 'vibrance',
+ 'output'
+];
+
+export class ColorGrading extends Extension {
+
+ constructor( options = {} ) {
+
+ super( 'Color Grading', options );
+
+ LUT3DStyle.init();
+
+ this.params = createDefaultParams();
+ this.lutSize = 32;
+ this.lutTexture = null;
+ this.lutPassNode = null;
+ this.sceneGradingEnabled = false;
+ this.selectedToneMapping = NoToneMapping;
+ this._lutNeedsUpdate = true;
+ this._lutSizeChanged = false;
+ this.activeTab = 'wheels';
+
+ this.modulesMap = new Map();
+ this.cardsMap = new Map();
+
+ // Container Setup matching Inspector tabs
+ this.content.style.overflow = 'hidden';
+ this.content.style.display = 'flex';
+ this.content.style.flexDirection = 'column';
+ this.content.style.height = '100%';
+ this.content.style.background = 'transparent';
+
+ this._buildHeader();
+ this._buildMainView();
+
+ }
+
+ _createSvgIcon( svgPath, width = 13, height = 13, strokeWidth = 2 ) {
+
+ const ns = 'http://www.w3.org/2000/svg';
+ const svg = document.createElementNS( ns, 'svg' );
+ svg.setAttribute( 'width', String( width ) );
+ svg.setAttribute( 'height', String( height ) );
+ svg.setAttribute( 'viewBox', '0 0 24 24' );
+ svg.setAttribute( 'fill', 'none' );
+ svg.setAttribute( 'stroke', 'currentColor' );
+ svg.setAttribute( 'stroke-width', String( strokeWidth ) );
+ svg.setAttribute( 'stroke-linecap', 'round' );
+ svg.setAttribute( 'stroke-linejoin', 'round' );
+ svg.style.display = 'block';
+ svg.innerHTML = svgPath;
+ return svg;
+
+ }
+
+ _buildHeader() {
+
+ // All controls consolidated into sleek Control Dock inside lut-container
+
+ }
+
+ _buildMainView() {
+
+ this.viewContainer = document.createElement( 'div' );
+ this.viewContainer.className = this.sceneGradingEnabled ? 'lut-container is-live-active' : 'lut-container';
+
+ // Glass Control Dock Container at top of lut-container
+ const dock = document.createElement( 'div' );
+ dock.className = 'lut-dock-container';
+ this.dockContainer = dock;
+
+ // 1. LEFT GROUP: Grid Size
+ const leftGroup = document.createElement( 'div' );
+ leftGroup.className = 'lut-dock-group';
+
+ const sizeLabel = document.createElement( 'span' );
+ sizeLabel.className = 'lut-dock-label';
+ sizeLabel.textContent = 'Grid';
+
+ const sizeSelect = document.createElement( 'select' );
+ sizeSelect.className = 'lut-dock-select';
+ this.lutSizeSelect = sizeSelect;
+
+ const sizes = [ 16, 32, 64 ];
+ sizes.forEach( size => {
+
+ const opt = document.createElement( 'option' );
+ opt.value = size;
+ opt.textContent = `${size}³`;
+ if ( size === this.lutSize ) opt.selected = true;
+ sizeSelect.appendChild( opt );
+
+ } );
+
+ sizeSelect.onchange = ( e ) => {
+
+ this.lutSize = parseInt( e.target.value, 10 );
+ this._lutSizeChanged = true;
+ this._onParamChange();
+
+ };
+
+ leftGroup.appendChild( sizeLabel );
+ leftGroup.appendChild( sizeSelect );
+
+ // 2. CENTER GROUP: Live Preview Button
+ const centerGroup = document.createElement( 'div' );
+ centerGroup.className = 'lut-dock-group';
+
+ const gradingBtn = document.createElement( 'button' );
+ gradingBtn.className = 'lut-dock-btn';
+ gradingBtn.title = 'Toggle Realtime Scene Color Grading';
+ this.gradingBtn = gradingBtn;
+
+ const updateGradingBtnState = () => {
+
+ gradingBtn.innerHTML = '';
+ const eyeIcon = this._createSvgIcon( '', 13, 13 );
+ const lbl = document.createElement( 'span' );
+
+ if ( this.sceneGradingEnabled ) {
+
+ if ( this.viewContainer ) this.viewContainer.classList.add( 'is-live-active' );
+ gradingBtn.className = 'lut-dock-btn active';
+ lbl.textContent = 'Live Preview';
+ lbl.className = 'lut-dock-text';
+
+ } else {
+
+ if ( this.viewContainer ) this.viewContainer.classList.remove( 'is-live-active' );
+ gradingBtn.className = 'lut-dock-btn';
+ lbl.textContent = 'Live Preview';
+ lbl.className = 'lut-dock-text';
+
+ }
+
+ gradingBtn.appendChild( eyeIcon );
+ gradingBtn.appendChild( lbl );
+ this._updateRendererCardState();
+
+ };
+
+ this.updateGradingBtnState = updateGradingBtnState;
+
+ gradingBtn.onclick = () => {
+
+ this.sceneGradingEnabled = ! this.sceneGradingEnabled;
+ updateGradingBtnState();
+ this._toggleSceneGrading( this.sceneGradingEnabled );
+ this.save();
+
+ };
+
+ updateGradingBtnState();
+ centerGroup.appendChild( gradingBtn );
+
+ // Active context menu tracker
+ let activeContextMenu = null;
+
+ const closeContextMenu = () => {
+
+ if ( activeContextMenu ) {
+
+ activeContextMenu.remove();
+ activeContextMenu = null;
+
+ }
+
+ };
+
+ window.addEventListener( 'pointerdown', ( e ) => {
+
+ if ( activeContextMenu && ! activeContextMenu.contains( e.target ) ) {
+
+ closeContextMenu();
+
+ }
+
+ } );
+
+ const showContextMenu = ( parentNode, items, isSubmenu = false ) => {
+
+ if ( ! isSubmenu ) closeContextMenu();
+
+ const menu = document.createElement( 'div' );
+ menu.className = isSubmenu ? 'lut-context-menu lut-submenu' : 'lut-context-menu';
+
+ let activeSubmenu = null;
+
+ const closeActiveSubmenu = () => {
+
+ if ( activeSubmenu ) {
+
+ activeSubmenu.remove();
+ activeSubmenu = null;
+
+ }
+
+ };
+
+ items.forEach( item => {
+
+ if ( item.divider ) {
+
+ const div = document.createElement( 'div' );
+ div.className = 'lut-menu-divider';
+ menu.appendChild( div );
+ return;
+
+ }
+
+ const row = document.createElement( 'div' );
+ row.className = 'lut-menu-item';
+
+ if ( item.iconPath ) {
+
+ const icon = this._createSvgIcon( item.iconPath, 13, 13 );
+ row.appendChild( icon );
+
+ }
+
+ const label = document.createElement( 'span' );
+ label.textContent = item.label;
+ label.style.flex = '1';
+ row.appendChild( label );
+
+ if ( item.submenu ) {
+
+ const arrow = document.createElement( 'span' );
+ arrow.textContent = '▸';
+ arrow.style.cssText = 'font-size: 10px; opacity: 0.6; margin-left: 6px;';
+ row.appendChild( arrow );
+
+ row.style.position = 'relative';
+
+ const toggleSubmenu = ( e ) => {
+
+ e.stopPropagation();
+ if ( activeSubmenu ) {
+
+ closeActiveSubmenu();
+
+ } else {
+
+ activeSubmenu = showContextMenu( row, item.submenu, true );
+
+ }
+
+ };
+
+ row.onmouseenter = () => {
+
+ closeActiveSubmenu();
+ activeSubmenu = showContextMenu( row, item.submenu, true );
+
+ };
+
+ row.onclick = toggleSubmenu;
+
+ } else if ( item.action ) {
+
+ row.onmouseenter = () => closeActiveSubmenu();
+
+ row.onclick = ( e ) => {
+
+ e.stopPropagation();
+ closeContextMenu();
+ item.action();
+
+ };
+
+ }
+
+ menu.appendChild( row );
+
+ } );
+
+ if ( ! isSubmenu ) {
+
+ parentNode.style.position = 'relative';
+ activeContextMenu = menu;
+
+ }
+
+ parentNode.appendChild( menu );
+ return menu;
+
+ };
+
+ // 3. RIGHT GROUP: Action Buttons with Context Menus
+ const rightGroup = document.createElement( 'div' );
+ rightGroup.className = 'lut-dock-group';
+
+ const createDockBtn = ( title, text, svgPath, onClick ) => {
+
+ const btn = document.createElement( 'button' );
+ btn.className = text ? 'lut-dock-btn' : 'lut-dock-btn lut-dock-icon-btn';
+ btn.title = title;
+
+ if ( svgPath ) {
+
+ const icon = this._createSvgIcon( svgPath, 13, 13 );
+ btn.appendChild( icon );
+
+ }
+
+ if ( text ) {
+
+ const txt = document.createElement( 'span' );
+ txt.textContent = text;
+ btn.appendChild( txt );
+
+ }
+
+ btn.onclick = onClick;
+ return btn;
+
+ };
+
+ const importBtn = createDockBtn(
+ 'Import (.CUBE / JSON / Presets)',
+ '▾',
+ '',
+ ( e ) => {
+
+ e.stopPropagation();
+
+ const presetItems = Object.keys( LUT_PRESETS ).map( presetName => ( {
+ label: presetName,
+ iconPath: '',
+ action: () => this.load( { params: LUT_PRESETS[ presetName ] } )
+ } ) );
+
+ showContextMenu( importBtn, [
+ {
+ label: 'Load JSON',
+ iconPath: '',
+ action: () => this._importJsonFile()
+ },
+ {
+ label: 'Import Adobe .CUBE',
+ iconPath: '',
+ action: () => this._importCubeFile()
+ },
+ { divider: true },
+ {
+ label: 'Presets',
+ iconPath: '',
+ submenu: presetItems
+ }
+ ] );
+
+ }
+ );
+
+ const exportBtn = createDockBtn(
+ 'Export (.CUBE / PNG / JSON)',
+ '▾',
+ '',
+ ( e ) => {
+
+ e.stopPropagation();
+ showContextMenu( exportBtn, [
+ {
+ label: 'Export JSON',
+ iconPath: '',
+ action: () => this._exportJsonFile()
+ },
+ { divider: true },
+ {
+ label: 'Export .CUBE (3D LUT)',
+ iconPath: '',
+ action: () => this._exportCubeFile()
+ },
+ {
+ label: 'Export PNG (2D LUT)',
+ iconPath: '',
+ action: () => this._exportPngFile()
+ }
+ ] );
+
+ }
+ );
+
+ const resetBtn = createDockBtn(
+ 'Reset',
+ null,
+ '',
+ () => {
+
+ // Reset component pipeline order to default
+ this.pipelineOrder = [ ...DEFAULT_PIPELINE_ORDER ];
+
+ // Remove any custom or duplicated cards if present
+ this.modulesMap.forEach( ( comp, modId ) => {
+
+ if ( ! DEFAULT_PIPELINE_ORDER.includes( modId ) ) {
+
+ if ( comp && comp.domElement ) comp.domElement.remove();
+ this.modulesMap.delete( modId );
+ this.cardsMap.delete( modId );
+
+ }
+
+ } );
+
+ // Re-render pipeline cards flow
+ this._renderCardsFlow();
+
+ // Reset all module components
+ this.modulesMap.forEach( ( comp ) => {
+
+ if ( comp && typeof comp.reset === 'function' ) {
+
+ comp.reset();
+
+ }
+
+ } );
+
+ // Reset all color grading parameters to neutral default
+ this.load( { params: LUT_PRESETS[ 'Neutral (Default)' ] } );
+
+ }
+ );
+
+ rightGroup.appendChild( importBtn );
+ rightGroup.appendChild( exportBtn );
+ rightGroup.appendChild( resetBtn );
+
+ dock.appendChild( leftGroup );
+ dock.appendChild( centerGroup );
+ dock.appendChild( rightGroup );
+
+ this.viewContainer.appendChild( dock );
+
+ const panel = document.createElement( 'div' );
+ panel.className = 'lut-panel';
+
+ // Single Unified Horizontal Flow Cards Row
+ this.cardsRow = document.createElement( 'div' );
+ this.cardsRow.className = 'lut-cards-row';
+
+ // Default calculation pipeline sequence
+ this.pipelineOrder = [ ...DEFAULT_PIPELINE_ORDER ];
+
+ // Initialize Module instances
+ const onParamChange = () => this._onParamChange();
+
+ const rendererComp = new RendererModule(
+ { toneMapping: this.selectedToneMapping, exposure: 1.0 },
+ onParamChange,
+ ( comp ) => {
+
+ this.selectedToneMapping = comp.params.toneMapping;
+ if ( this.sceneGradingEnabled ) {
+
+ const renderer = this.inspector ? this.inspector.getRenderer() : null;
+ if ( renderer ) {
+
+ renderer.toneMappingExposure = comp.params.exposure;
+
+ }
+
+ this._applyLiveGrading();
+
+ }
+
+ this.save();
+
+ }
+ );
+
+ const wbComp = new WhiteBalanceModule( this.params, onParamChange, () => this._removeCard( 'whiteBalance' ) );
+
+ const liftComp = new ColorWheelModule( 'lift', 'Shadows', this.params.lift, onParamChange, () => this._removeCard( 'lift' ) );
+ const gammaBalComp = new ColorWheelModule( 'gammaBal', 'Midtones', this.params.gammaBal, onParamChange, () => this._removeCard( 'gammaBal' ) );
+ const gainComp = new ColorWheelModule( 'gain', 'Highlights', this.params.gain, onParamChange, () => this._removeCard( 'gain' ) );
+ const offsetComp = new ColorWheelModule( 'offset', 'Offset', this.params.offset || { r: 0, g: 0, b: 0 }, onParamChange, () => this._removeCard( 'offset' ) );
+
+ const curvesComp = new CurvesModule( this.params.curves, onParamChange, () => this._removeCard( 'curves' ) );
+ const satComp = new SaturationModule( this.params, onParamChange, () => this._removeCard( 'saturation' ) );
+ const vibranceComp = new VibranceModule( this.params, onParamChange, () => this._removeCard( 'vibrance' ) );
+ const outputComp = new OutputModule( {}, onParamChange );
+
+ this.modulesMap = new Map( [
+ [ 'renderer', rendererComp ],
+ [ 'whiteBalance', wbComp ],
+ [ 'lift', liftComp ],
+ [ 'gammaBal', gammaBalComp ],
+ [ 'gain', gainComp ],
+ [ 'offset', offsetComp ],
+ [ 'curves', curvesComp ],
+ [ 'saturation', satComp ],
+ [ 'vibrance', vibranceComp ],
+ [ 'output', outputComp ]
+ ] );
+
+ this.cardsMap = new Map();
+
+ this.modulesMap.forEach( ( comp, id ) => {
+
+ this.cardsMap.set( id, comp.domElement );
+ this._setupCardDragAndDrop( comp.domElement, id );
+
+ } );
+
+ // Render initial flow of cards separated by connector lines
+ this._renderCardsFlow();
+
+ // Enable mouse wheel horizontal scrolling & background drag-to-pan
+ this._setupPanAndWheelScroll( panel, this.cardsRow );
+
+ if ( typeof ResizeObserver !== 'undefined' ) {
+
+ this._cardsResizeObserver = new ResizeObserver( () => this._updateCardsRowAlignment() );
+ this._cardsResizeObserver.observe( this.cardsRow );
+
+ }
+
+ this._windowResizeHandler = () => this._updateCardsRowAlignment();
+ window.addEventListener( 'resize', this._windowResizeHandler );
+
+ panel.appendChild( this.cardsRow );
+ this.viewContainer.appendChild( panel );
+ this.content.appendChild( this.viewContainer );
+
+ }
+
+ _isVerticalMode() {
+
+ if ( ! this.cardsRow ) return false;
+ const panelEl = this.content ? this.content.closest( '.profiler-panel' ) : null;
+ return this.cardsRow.classList.contains( 'is-vertical' ) ||
+ ( this.viewContainer && this.viewContainer.classList.contains( 'is-vertical' ) ) ||
+ ( panelEl && ( panelEl.classList.contains( 'position-left' ) || panelEl.classList.contains( 'position-right' ) ) );
+
+ }
+
+ _updateCardsRowAlignment() {
+
+ if ( ! this.cardsRow ) return;
+
+ requestAnimationFrame( () => {
+
+ if ( ! this.cardsRow ) return;
+
+ const isVert = this._isVerticalMode();
+
+ if ( isVert ) {
+
+ const hasVertOverflow = this.cardsRow.scrollHeight > ( this.cardsRow.clientHeight + 2 );
+
+ if ( hasVertOverflow ) {
+
+ this.cardsRow.style.setProperty( 'justify-content', 'flex-start', 'important' );
+
+ } else {
+
+ this.cardsRow.style.setProperty( 'justify-content', 'center', 'important' );
+
+ }
+
+ this.cardsRow.style.setProperty( 'align-items', 'center', 'important' );
+
+ } else {
+
+ const hasOverflow = this.cardsRow.scrollWidth > ( this.cardsRow.clientWidth + 2 );
+
+ if ( hasOverflow ) {
+
+ this.cardsRow.style.setProperty( 'justify-content', 'flex-start', 'important' );
+
+ } else {
+
+ this.cardsRow.style.setProperty( 'justify-content', 'center', 'important' );
+
+ }
+
+ this.cardsRow.style.setProperty( 'align-items', 'center', 'important' );
+
+ }
+
+ } );
+
+ }
+
+ _setupPanAndWheelScroll( container, cardsRow ) {
+
+ if ( ! cardsRow || ! container ) return;
+
+ let targetScroll = 0;
+ let isAnimating = false;
+
+ const isVerticalMode = () => this._isVerticalMode();
+
+ const updateSmoothScroll = () => {
+
+ const isVert = isVerticalMode();
+ const current = isVert ? cardsRow.scrollTop : cardsRow.scrollLeft;
+ const diff = targetScroll - current;
+
+ if ( Math.abs( diff ) > 0.5 ) {
+
+ if ( isVert ) {
+
+ cardsRow.scrollTop += diff * 0.18;
+
+ } else {
+
+ cardsRow.scrollLeft += diff * 0.18;
+
+ }
+
+ requestAnimationFrame( updateSmoothScroll );
+
+ } else {
+
+ if ( isVert ) {
+
+ cardsRow.scrollTop = targetScroll;
+
+ } else {
+
+ cardsRow.scrollLeft = targetScroll;
+
+ }
+
+ isAnimating = false;
+
+ }
+
+ };
+
+ cardsRow.addEventListener( 'wheel', ( e ) => {
+
+ if ( isVerticalMode() ) {
+
+ return; // Native vertical scrolling in vertical mode
+
+ }
+
+ if ( e.deltaY !== 0 ) {
+
+ e.preventDefault();
+
+ const maxScroll = cardsRow.scrollWidth - cardsRow.clientWidth;
+ if ( ! isAnimating ) {
+
+ targetScroll = cardsRow.scrollLeft;
+
+ }
+
+ targetScroll = Math.max( 0, Math.min( maxScroll, targetScroll + e.deltaY * 1.2 ) );
+
+ if ( ! isAnimating ) {
+
+ isAnimating = true;
+ requestAnimationFrame( updateSmoothScroll );
+
+ }
+
+ }
+
+ }, { passive: false } );
+
+ let isPanning = false;
+ let startPos = 0;
+ let scrollStart = 0;
+ let lastPos = 0;
+ let lastTime = 0;
+ let velocity = 0;
+ let momentumRafId = null;
+
+ const startPan = ( e ) => {
+
+ if ( e.pointerType === 'touch' ) return;
+ if ( e.target.closest( '.lut-card' ) || e.target.closest( '.lut-dock-container' ) ) return;
+
+ if ( momentumRafId ) {
+
+ cancelAnimationFrame( momentumRafId );
+ momentumRafId = null;
+
+ }
+
+ isPanning = true;
+ cardsRow.classList.add( 'is-panning' );
+ container.classList.add( 'is-panning' );
+
+ const isVert = isVerticalMode();
+ const currentPos = isVert ? ( e.pageY - cardsRow.offsetTop ) : ( e.pageX - cardsRow.offsetLeft );
+
+ startPos = currentPos;
+ lastPos = currentPos;
+ lastTime = performance.now();
+ velocity = 0;
+ scrollStart = isVert ? cardsRow.scrollTop : cardsRow.scrollLeft;
+ targetScroll = scrollStart;
+
+ };
+
+ const movePan = ( e ) => {
+
+ if ( ! isPanning ) return;
+ e.preventDefault();
+
+ const isVert = isVerticalMode();
+ const currentPos = isVert ? ( e.pageY - cardsRow.offsetTop ) : ( e.pageX - cardsRow.offsetLeft );
+ const currentTime = performance.now();
+
+ const deltaPos = currentPos - lastPos;
+ const deltaTime = Math.max( 1, currentTime - lastTime );
+
+ const instantVelocity = deltaPos / deltaTime;
+ velocity = 0.5 * velocity + 0.5 * instantVelocity;
+
+ lastPos = currentPos;
+ lastTime = currentTime;
+
+ const walk = currentPos - startPos;
+
+ if ( isVert ) {
+
+ cardsRow.scrollTop = scrollStart - walk;
+ targetScroll = cardsRow.scrollTop;
+
+ } else {
+
+ cardsRow.scrollLeft = scrollStart - walk;
+ targetScroll = cardsRow.scrollLeft;
+
+ }
+
+ };
+
+ const endPan = () => {
+
+ if ( ! isPanning ) return;
+
+ isPanning = false;
+ cardsRow.classList.remove( 'is-panning' );
+ container.classList.remove( 'is-panning' );
+
+ const isVert = isVerticalMode();
+
+ // Apply smooth momentum deceleration physics
+ let currentVelocity = velocity;
+
+ const animateMomentum = () => {
+
+ if ( Math.abs( currentVelocity ) < 0.02 ) return;
+
+ const maxScroll = isVert ? ( cardsRow.scrollHeight - cardsRow.clientHeight ) : ( cardsRow.scrollWidth - cardsRow.clientWidth );
+
+ if ( isVert ) {
+
+ cardsRow.scrollTop = Math.max( 0, Math.min( maxScroll, cardsRow.scrollTop - currentVelocity * 16 ) );
+
+ } else {
+
+ cardsRow.scrollLeft = Math.max( 0, Math.min( maxScroll, cardsRow.scrollLeft - currentVelocity * 16 ) );
+
+ }
+
+ currentVelocity *= 0.94; // Friction factor
+
+ if ( Math.abs( currentVelocity ) >= 0.02 ) {
+
+ momentumRafId = requestAnimationFrame( animateMomentum );
+
+ }
+
+ };
+
+ momentumRafId = requestAnimationFrame( animateMomentum );
+
+ };
+
+ cardsRow.addEventListener( 'pointerdown', startPan );
+ window.addEventListener( 'pointermove', movePan );
+ window.addEventListener( 'pointerup', endPan );
+
+ }
+
+ _renderCardsFlow() {
+
+ if ( ! this.cardsRow ) return;
+
+ // Remove cards no longer in pipelineOrder
+ const existingCards = Array.from( this.cardsRow.children );
+ existingCards.forEach( child => {
+
+ const modId = child.getAttribute( 'data-module-id' );
+ if ( modId && ! this.pipelineOrder.includes( modId ) ) {
+
+ child.remove();
+
+ }
+
+ } );
+
+ // Clean old connectors
+ const connectors = this.cardsRow.querySelectorAll( '.lut-flow-connector' );
+ connectors.forEach( c => c.remove() );
+
+ // Guarantee Renderer at start (0) and Output at end (last)
+ if ( ! this.pipelineOrder.includes( 'renderer' ) ) {
+
+ this.pipelineOrder.unshift( 'renderer' );
+
+ } else {
+
+ const rIdx = this.pipelineOrder.indexOf( 'renderer' );
+ if ( rIdx !== 0 ) {
+
+ this.pipelineOrder.splice( rIdx, 1 );
+ this.pipelineOrder.unshift( 'renderer' );
+
+ }
+
+ }
+
+ if ( ! this.pipelineOrder.includes( 'output' ) ) {
+
+ this.pipelineOrder.push( 'output' );
+
+ } else {
+
+ const oIdx = this.pipelineOrder.indexOf( 'output' );
+ if ( oIdx !== this.pipelineOrder.length - 1 ) {
+
+ this.pipelineOrder.splice( oIdx, 1 );
+ this.pipelineOrder.push( 'output' );
+
+ }
+
+ }
+
+ // Re-append cards in current pipelineOrder
+ this.pipelineOrder.forEach( ( modId ) => {
+
+ const comp = this.modulesMap.get( modId );
+ if ( comp && comp.domElement ) {
+
+ comp.domElement.classList.remove( 'lut-card-moving' );
+ this.cardsRow.appendChild( comp.domElement );
+
+ }
+
+ } );
+
+ this._updateLiveFlowConnectors();
+ this._updateRendererCardState();
+ this._updateCardsRowAlignment();
+
+ }
+
+ _updateLiveFlowConnectors() {
+
+ if ( ! this.cardsRow ) return;
+
+ const oldConnectors = this.cardsRow.querySelectorAll( '.lut-flow-connector' );
+ oldConnectors.forEach( c => c.remove() );
+
+ const items = Array.from( this.cardsRow.children ).filter( el =>
+ el.classList.contains( 'lut-card' )
+ );
+
+ const createConnector = ( insertIndex, titleTip ) => {
+
+ const connector = document.createElement( 'div' );
+ connector.className = 'lut-flow-connector';
+ connector.title = titleTip;
+
+ const addBtn = document.createElement( 'button' );
+ addBtn.className = 'lut-connector-add-btn';
+ addBtn.appendChild( this._createSvgIcon( '', 12, 12, 2.5 ) );
+ addBtn.title = 'Add Card Here';
+ addBtn.onclick = ( e ) => {
+
+ e.stopPropagation();
+ this._openAddCardModal( insertIndex );
+
+ };
+
+ connector.appendChild( addBtn );
+ return connector;
+
+ };
+
+ for ( let i = 0; i < items.length - 1; i ++ ) {
+
+ const current = items[ i ];
+ const next = items[ i + 1 ];
+
+ const idA = current.getAttribute( 'data-module-id' ) || 'Origin';
+ const idB = next.getAttribute( 'data-module-id' ) || 'Target';
+
+ const connector = createConnector( i + 1, `Insert Card between ${idA} ➔ ${idB}` );
+ this.cardsRow.insertBefore( connector, next );
+
+ }
+
+ }
+
+ _openAddCardModal( insertIndex ) {
+
+ this._closeAddCardModal();
+
+ const overlay = document.createElement( 'div' );
+ overlay.className = 'lut-modal-overlay';
+ this._activeModalOverlay = overlay;
+
+ const content = document.createElement( 'div' );
+ content.className = 'lut-modal-content';
+
+ // Header
+ const header = document.createElement( 'div' );
+ header.className = 'lut-modal-header';
+
+ const title = document.createElement( 'div' );
+ title.className = 'lut-modal-title';
+ title.appendChild( this._createSvgIcon( '', 14, 14, 2.5 ) );
+
+ const titleTxt = document.createElement( 'span' );
+ titleTxt.textContent = 'Add to Pipeline';
+ title.appendChild( titleTxt );
+
+ const closeBtn = document.createElement( 'button' );
+ closeBtn.className = 'lut-modal-close-btn';
+ closeBtn.appendChild( this._createSvgIcon( '', 12, 12, 2.5 ) );
+ closeBtn.title = 'Close';
+ closeBtn.onclick = () => this._closeAddCardModal();
+
+ header.appendChild( title );
+ header.appendChild( closeBtn );
+ content.appendChild( header );
+
+ // Filter Input Search Bar
+ const filterBox = document.createElement( 'div' );
+ filterBox.className = 'lut-modal-filter-box';
+
+ const filterIcon = document.createElement( 'span' );
+ filterIcon.className = 'lut-modal-filter-icon';
+ filterIcon.appendChild( this._createSvgIcon( '', 13, 13, 2 ) );
+
+ const filterInput = document.createElement( 'input' );
+ filterInput.type = 'text';
+ filterInput.className = 'lut-modal-filter-input';
+ filterInput.placeholder = 'Filter...';
+
+ filterBox.appendChild( filterIcon );
+ filterBox.appendChild( filterInput );
+ content.appendChild( filterBox );
+
+ // Options Grid
+ const grid = document.createElement( 'div' );
+ grid.className = 'lut-modal-grid';
+
+ const emptyMsg = document.createElement( 'div' );
+ emptyMsg.className = 'lut-modal-empty-msg';
+ emptyMsg.textContent = 'No cards match your filter';
+ emptyMsg.style.display = 'none';
+
+ const cardOptions = [
+ {
+ type: 'whiteBalance',
+ name: 'White Balance',
+ iconSvg: ''
+ },
+ {
+ type: 'exposure',
+ name: 'Exposure',
+ iconSvg: ''
+ },
+ {
+ type: 'brightness',
+ name: 'Brightness',
+ iconSvg: ''
+ },
+ {
+ type: 'hue',
+ name: 'Hue Shift',
+ iconSvg: ''
+ },
+ {
+ type: 'lift',
+ name: 'Shadows (Lift)',
+ iconSvg: ''
+ },
+ {
+ type: 'gammaBal',
+ name: 'Midtones (Gamma)',
+ iconSvg: ''
+ },
+ {
+ type: 'gain',
+ name: 'Highlights (Gain)',
+ iconSvg: ''
+ },
+ {
+ type: 'offset',
+ name: 'Offset',
+ iconSvg: ''
+ },
+ {
+ type: 'curves',
+ name: 'Curves',
+ iconSvg: ''
+ },
+ {
+ type: 'contrast',
+ name: 'Contrast & Pivot',
+ iconSvg: ''
+ },
+ {
+ type: 'saturation',
+ name: 'Saturation',
+ iconSvg: ''
+ },
+ {
+ type: 'vibrance',
+ name: 'Vibrance',
+ iconSvg: ''
+ },
+ {
+ type: 'importedCube',
+ name: 'Import .CUBE',
+ iconSvg: ''
+ }
+ ];
+
+ const optionElements = [];
+
+ cardOptions.forEach( opt => {
+
+ const item = document.createElement( 'div' );
+ item.className = 'lut-modal-option';
+
+ const icon = document.createElement( 'div' );
+ icon.className = 'lut-modal-option-icon';
+ icon.appendChild( this._createSvgIcon( opt.iconSvg, 20, 20, 2 ) );
+
+ const optTitle = document.createElement( 'div' );
+ optTitle.className = 'lut-modal-option-title';
+ optTitle.textContent = opt.name;
+
+ item.appendChild( icon );
+ item.appendChild( optTitle );
+
+ const selectAction = () => {
+
+ this._closeAddCardModal();
+ if ( opt.type === 'importedCube' ) {
+
+ this._importCubeFileAt( insertIndex );
+
+ } else {
+
+ this._addCardAt( opt.type, insertIndex );
+
+ }
+
+ };
+
+ item.onclick = selectAction;
+
+ grid.appendChild( item );
+ optionElements.push( { el: item, name: opt.name.toLowerCase(), select: selectAction } );
+
+ } );
+
+ grid.appendChild( emptyMsg );
+
+ const updateFocusState = ( query, visibleCount ) => {
+
+ let singleVisibleObj = null;
+ if ( query.length > 0 && visibleCount === 1 ) {
+
+ singleVisibleObj = optionElements.find( obj => obj.el.style.display !== 'none' );
+
+ }
+
+ optionElements.forEach( obj => {
+
+ if ( singleVisibleObj && obj === singleVisibleObj ) {
+
+ obj.el.classList.add( 'is-focused' );
+
+ } else {
+
+ obj.el.classList.remove( 'is-focused' );
+
+ }
+
+ } );
+
+ };
+
+ filterInput.oninput = () => {
+
+ const query = filterInput.value.trim().toLowerCase();
+ let visibleCount = 0;
+
+ optionElements.forEach( obj => {
+
+ const matches = ! query || obj.name.includes( query );
+ obj.el.style.display = matches ? 'flex' : 'none';
+ if ( matches ) visibleCount ++;
+
+ } );
+
+ emptyMsg.style.display = visibleCount === 0 ? 'block' : 'none';
+
+ if ( visibleCount === 1 ) {
+
+ grid.style.justifyContent = 'center';
+ grid.style.alignContent = 'center';
+ grid.style.gridTemplateColumns = 'minmax(120px, 150px)';
+
+ } else {
+
+ grid.style.justifyContent = 'start';
+ grid.style.alignContent = 'start';
+ grid.style.gridTemplateColumns = 'repeat(auto-fill, minmax(120px, 1fr))';
+
+ }
+
+ updateFocusState( query, visibleCount );
+
+ };
+
+ updateFocusState( '', optionElements.length );
+
+ const handleKeyDown = ( e ) => {
+
+ if ( e.key === 'Escape' || e.code === 'Escape' || e.keyCode === 27 ) {
+
+ e.preventDefault();
+ e.stopImmediatePropagation();
+ this._closeAddCardModal();
+
+ } else if ( e.key === 'Enter' || e.code === 'Enter' || e.keyCode === 13 ) {
+
+ e.preventDefault();
+ e.stopImmediatePropagation();
+ const visibleItem = optionElements.find( obj => obj.el.style.display !== 'none' );
+ if ( visibleItem ) {
+
+ visibleItem.select();
+
+ }
+
+ }
+
+ };
+
+ window.addEventListener( 'keydown', handleKeyDown, true );
+ this._modalKeyDownHandler = handleKeyDown;
+
+ content.appendChild( grid );
+ overlay.appendChild( content );
+
+ setTimeout( () => filterInput.focus(), 50 );
+
+ overlay.onclick = ( e ) => {
+
+ if ( e.target === overlay ) {
+
+ this._closeAddCardModal();
+
+ }
+
+ };
+
+ if ( this.viewContainer ) {
+
+ this.viewContainer.appendChild( overlay );
+
+ }
+
+ }
+
+ _closeAddCardModal() {
+
+ if ( this._modalKeyDownHandler ) {
+
+ window.removeEventListener( 'keydown', this._modalKeyDownHandler, true );
+ this._modalKeyDownHandler = null;
+
+ }
+
+ if ( this._activeModalOverlay ) {
+
+ this._activeModalOverlay.remove();
+ this._activeModalOverlay = null;
+
+ }
+
+ }
+
+ _addCardAt( type, insertIndex, initialParams = {}, customId = null ) {
+
+ const modId = customId || ( `${type}_${Date.now()}_${Math.floor( Math.random() * 1000 )}` );
+
+ const onParamChange = () => this._onParamChange();
+ const onRemove = () => this._removeCard( modId );
+
+ let comp = null;
+
+ switch ( type ) {
+
+ case 'whiteBalance':
+ comp = new WhiteBalanceModule( initialParams, onParamChange, onRemove, modId );
+ break;
+
+ case 'exposure':
+ case 'exposureHue':
+ comp = new ExposureModule( initialParams, onParamChange, onRemove, modId );
+ break;
+
+ case 'brightness':
+ comp = new BrightnessModule( initialParams, onParamChange, onRemove, modId );
+ break;
+
+ case 'hue':
+ comp = new HueModule( initialParams, onParamChange, onRemove, modId );
+ break;
+
+ case 'lift':
+ comp = new ColorWheelModule( 'lift', 'Shadows', initialParams, onParamChange, onRemove, modId );
+ break;
+
+ case 'gammaBal':
+ comp = new ColorWheelModule( 'gammaBal', 'Midtones', initialParams, onParamChange, onRemove, modId );
+ break;
+
+ case 'gain':
+ comp = new ColorWheelModule( 'gain', 'Highlights', initialParams, onParamChange, onRemove, modId );
+ break;
+
+ case 'offset':
+ comp = new ColorWheelModule( 'offset', 'Offset', initialParams, onParamChange, onRemove, modId );
+ break;
+
+ case 'curves':
+ comp = new CurvesModule( initialParams, onParamChange, onRemove, modId );
+ break;
+
+ case 'contrast':
+ comp = new ContrastModule( initialParams, onParamChange, onRemove, modId );
+ break;
+
+ case 'saturation':
+ comp = new SaturationModule( initialParams, onParamChange, onRemove, modId );
+ break;
+
+ case 'vibrance':
+ case 'satVibrance':
+ comp = new VibranceModule( initialParams, onParamChange, onRemove, modId );
+ break;
+
+ default:
+ console.warn( `Unknown card module type: ${type}` );
+ return null;
+
+ }
+
+ this.modulesMap.set( modId, comp );
+ this.cardsMap.set( modId, comp.domElement );
+
+ const targetIdx = Math.max( 0, Math.min( this.pipelineOrder.length, insertIndex ) );
+ this.pipelineOrder.splice( targetIdx, 0, modId );
+
+ this._setupCardDragAndDrop( comp.domElement, modId );
+ this._renderCardsFlow();
+ this._onParamChange();
+
+ if ( comp.domElement ) {
+
+ setTimeout( () => {
+
+ comp.domElement.scrollIntoView( { behavior: 'smooth', block: 'nearest', inline: 'center' } );
+
+ }, 50 );
+
+ }
+
+ return comp;
+
+ }
+
+ _removeCard( modId ) {
+
+ const comp = this.modulesMap.get( modId );
+ if ( comp ) {
+
+ if ( comp.domElement ) comp.domElement.remove();
+ this.modulesMap.delete( modId );
+ this.cardsMap.delete( modId );
+
+ }
+
+ const idx = this.pipelineOrder.indexOf( modId );
+ if ( idx !== - 1 ) {
+
+ this.pipelineOrder.splice( idx, 1 );
+
+ }
+
+ this._renderCardsFlow();
+ this._onParamChange();
+
+ }
+
+ _setupCardDragAndDrop( card, moduleId ) {
+
+ card.setAttribute( 'data-module-id', moduleId );
+
+ const comp = this.modulesMap.get( moduleId );
+ const isDraggable = comp ? comp.dragAndDrop !== false : true;
+
+ if ( ! isDraggable ) {
+
+ card.classList.add( 'lut-card-nodrag' );
+
+ }
+
+ const header = card.querySelector( '.lut-card-header' );
+
+ if ( header ) {
+
+ if ( isDraggable ) {
+
+ header.draggable = true;
+
+ if ( ! header.querySelector( '.lut-card-drag-handle' ) ) {
+
+ const dragHandle = document.createElement( 'div' );
+ dragHandle.className = 'lut-card-drag-handle';
+ header.insertBefore( dragHandle, header.firstChild );
+
+ }
+
+ header.ondragstart = ( e ) => {
+
+ e.dataTransfer.setData( 'text/plain', moduleId );
+ e.dataTransfer.effectAllowed = 'move';
+
+ const rect = card.getBoundingClientRect();
+ const xOffset = e.clientX - rect.left;
+ const yOffset = e.clientY - rect.top;
+ e.dataTransfer.setDragImage( card, xOffset, yOffset );
+
+ this._draggedModuleId = moduleId;
+
+ setTimeout( () => {
+
+ card.classList.add( 'lut-card-moving' );
+
+ }, 0 );
+
+ this._updateLiveFlowConnectors();
+
+ };
+
+ header.ondragend = () => {
+
+ card.classList.remove( 'lut-card-moving' );
+ this._draggedModuleId = null;
+
+ const newOrder = [];
+ const childCards = this.cardsRow.querySelectorAll( '.lut-card' );
+ childCards.forEach( childCard => {
+
+ const id = childCard.getAttribute( 'data-module-id' );
+ if ( id && ! newOrder.includes( id ) ) {
+
+ newOrder.push( id );
+
+ }
+
+ } );
+
+ if ( newOrder.length > 0 ) {
+
+ this.pipelineOrder = newOrder;
+
+ }
+
+ this._renderCardsFlow();
+ this._onParamChange();
+
+ };
+
+ } else {
+
+ header.draggable = false;
+
+ }
+
+ }
+
+ card.ondragover = ( e ) => {
+
+ e.preventDefault();
+ e.dataTransfer.dropEffect = 'move';
+
+ if ( this._draggedModuleId && this.cardsMap.has( this._draggedModuleId ) ) {
+
+ const draggedCard = this.cardsMap.get( this._draggedModuleId );
+ if ( card === draggedCard ) return;
+
+ const targetComp = this.modulesMap.get( moduleId );
+ if ( targetComp && targetComp.dragAndDrop === false ) return;
+
+ const draggedComp = this.modulesMap.get( this._draggedModuleId );
+ if ( draggedComp && draggedComp.dragAndDrop === false ) return;
+
+ const rect = card.getBoundingClientRect();
+ const isVert = this._isVerticalMode();
+
+ const childCards = Array.from( this.cardsRow.querySelectorAll( '.lut-card' ) );
+ const draggedIndex = childCards.indexOf( draggedCard );
+ const targetIndex = childCards.indexOf( card );
+
+ let changed = false;
+
+ if ( isVert ) {
+
+ const relativeY = ( e.clientY - rect.top ) / rect.height;
+
+ if ( draggedIndex < targetIndex ) {
+
+ if ( relativeY > 0.05 ) {
+
+ if ( card.nextSibling !== draggedCard ) {
+
+ this.cardsRow.insertBefore( draggedCard, card.nextSibling );
+ changed = true;
+
+ }
+
+ }
+
+ } else {
+
+ if ( relativeY < 0.95 ) {
+
+ if ( card.previousSibling !== draggedCard ) {
+
+ this.cardsRow.insertBefore( draggedCard, card );
+ changed = true;
+
+ }
+
+ }
+
+ }
+
+ } else {
+
+ const relativeX = ( e.clientX - rect.left ) / rect.width;
+
+ if ( draggedIndex < targetIndex ) {
+
+ if ( relativeX > 0.05 ) {
+
+ if ( card.nextSibling !== draggedCard ) {
+
+ this.cardsRow.insertBefore( draggedCard, card.nextSibling );
+ changed = true;
+
+ }
+
+ }
+
+ } else {
+
+ if ( relativeX < 0.95 ) {
+
+ if ( card.previousSibling !== draggedCard ) {
+
+ this.cardsRow.insertBefore( draggedCard, card );
+ changed = true;
+
+ }
+
+ }
+
+ }
+
+ }
+
+ if ( changed ) {
+
+ this._updateLiveFlowConnectors();
+
+ }
+
+ }
+
+ };
+
+ card.ondrop = ( e ) => {
+
+ e.preventDefault();
+
+ };
+
+ }
+
+ _updateRendererCardState() {
+
+ if ( ! this.cardsRow ) return;
+
+ const cards = this.cardsRow.querySelectorAll( '.lut-card' );
+ cards.forEach( card => {
+
+ if ( this.sceneGradingEnabled ) {
+
+ card.style.opacity = '1';
+ card.style.pointerEvents = 'auto';
+
+ } else {
+
+ card.style.opacity = '0.4';
+ card.style.pointerEvents = 'none';
+
+ }
+
+ } );
+
+ }
+
+ _onParamChange() {
+
+ this._lutNeedsUpdate = true;
+
+ if ( this.sceneGradingEnabled ) {
+
+ this._applyLiveGrading();
+
+ }
+
+ this.save();
+
+ }
+
+ generate3DLUTData( size = this.lutSize, buffer = null ) {
+
+ if ( buffer === null ) {
+
+ buffer = new Float32Array( size * size * size * 4 );
+
+ }
+
+ let idx = 0;
+ const norm = 1.0 / ( size - 1 );
+ const _pixel = [ 0, 0, 0 ];
+
+ const activeModules = [];
+ for ( let i = 0; i < this.pipelineOrder.length; i ++ ) {
+
+ const mod = this.modulesMap.get( this.pipelineOrder[ i ] );
+ if ( mod && mod.enabled ) {
+
+ activeModules.push( mod );
+
+ }
+
+ }
+
+ const numModules = activeModules.length;
+
+ for ( let b = 0; b < size; b ++ ) {
+
+ for ( let g = 0; g < size; g ++ ) {
+
+ for ( let r = 0; r < size; r ++ ) {
+
+ _pixel[ 0 ] = r * norm;
+ _pixel[ 1 ] = g * norm;
+ _pixel[ 2 ] = b * norm;
+
+ for ( let m = 0; m < numModules; m ++ ) {
+
+ activeModules[ m ].applyPixel( _pixel[ 0 ], _pixel[ 1 ], _pixel[ 2 ], _pixel );
+
+ }
+
+ buffer[ idx ++ ] = _pixel[ 0 ];
+ buffer[ idx ++ ] = _pixel[ 1 ];
+ buffer[ idx ++ ] = _pixel[ 2 ];
+ buffer[ idx ++ ] = 1.0;
+
+ }
+
+ }
+
+ }
+
+ return buffer;
+
+ }
+
+ _getExportFileName( defaultBase = 'My Color Grading' ) {
+
+ const outputComp = this.modulesMap.get( 'output' );
+
+ if ( outputComp && outputComp.params && outputComp.params.fileName ) {
+
+ return outputComp.params.fileName;
+
+ }
+
+ return defaultBase;
+
+ }
+
+ _exportCubeFile() {
+
+ const baseName = this._getExportFileName();
+ const buffer = this.generate3DLUTData( this.lutSize );
+ const cubeContent = exportCubeFormat( buffer, this.lutSize, baseName );
+ const blob = new Blob( [ cubeContent ], { type: 'text/plain' } );
+ const url = URL.createObjectURL( blob );
+
+ const a = document.createElement( 'a' );
+ a.href = url;
+ a.download = `${baseName}_${this.lutSize}x${this.lutSize}.cube`;
+ a.click();
+ URL.revokeObjectURL( url );
+
+ }
+
+ _exportPngFile() {
+
+ const baseName = this._getExportFileName();
+ const buffer = this.generate3DLUTData( this.lutSize );
+ const canvas = exportLUTCanvas( buffer, this.lutSize );
+ canvas.toBlob( ( blob ) => {
+
+ const url = URL.createObjectURL( blob );
+ const a = document.createElement( 'a' );
+ a.href = url;
+ a.download = `${baseName}_${this.lutSize}x${this.lutSize}.png`;
+ a.click();
+ URL.revokeObjectURL( url );
+
+ } );
+
+ }
+
+ toJSON() {
+
+ const params = {};
+ const modulesData = {};
+
+ this.modulesMap.forEach( ( comp, id ) => {
+
+ if ( ! comp ) return;
+
+ let modType = 'whiteBalance';
+ let mode = null;
+
+ if ( comp.mode ) {
+
+ modType = 'colorWheel';
+ mode = comp.mode;
+
+ } else if ( id.startsWith( 'importedCube_' ) ) {
+
+ modType = 'importedCube';
+
+ } else if ( id.startsWith( 'whiteBalance' ) ) {
+
+ modType = 'whiteBalance';
+
+ } else if ( id.startsWith( 'exposure' ) ) {
+
+ modType = 'exposure';
+
+ } else if ( id.startsWith( 'brightness' ) ) {
+
+ modType = 'brightness';
+
+ } else if ( id.startsWith( 'hue' ) ) {
+
+ modType = 'hue';
+
+ } else if ( id.startsWith( 'curves' ) ) {
+
+ modType = 'curves';
+
+ } else if ( id.startsWith( 'contrast' ) ) {
+
+ modType = 'contrast';
+
+ } else if ( id.startsWith( 'saturation' ) ) {
+
+ modType = 'saturation';
+
+ } else if ( id.startsWith( 'vibrance' ) ) {
+
+ modType = 'vibrance';
+
+ } else if ( id === 'renderer' ) {
+
+ modType = 'renderer';
+
+ } else if ( id === 'output' ) {
+
+ modType = 'output';
+
+ }
+
+ modulesData[ id ] = {
+ type: modType,
+ mode: mode,
+ params: JSON.parse( JSON.stringify( comp.params ) )
+ };
+
+ if ( id === 'lift' || id === 'gammaBal' || id === 'gain' || id === 'offset' ) {
+
+ params[ id ] = JSON.parse( JSON.stringify( comp.params ) );
+
+ } else if ( id === 'curves' ) {
+
+ params.curves = JSON.parse( JSON.stringify( comp.params.curves ) );
+
+ } else if ( id.startsWith( 'importedCube_' ) ) {
+
+ if ( ! params.importedCubes ) params.importedCubes = {};
+ params.importedCubes[ id ] = JSON.parse( JSON.stringify( comp.params ) );
+
+ } else if ( id !== 'renderer' && ! id.includes( '_' ) ) {
+
+ Object.assign( params, JSON.parse( JSON.stringify( comp.params ) ) );
+
+ }
+
+ } );
+
+ return {
+ version: 2,
+ lutSize: this.lutSize,
+ selectedToneMapping: this.selectedToneMapping,
+ sceneGradingEnabled: this.sceneGradingEnabled,
+ pipelineOrder: [ ...this.pipelineOrder ],
+ modules: modulesData,
+ params: params
+ };
+
+ }
+
+ serialize() {
+
+ return this.toJSON();
+
+ }
+
+ deserialize( data ) {
+
+ if ( data ) {
+
+ this.load( data );
+
+ }
+
+ }
+
+ _applyParamsToModule( modId, params ) {
+
+ const comp = this.modulesMap.get( modId );
+ if ( ! comp ) return;
+
+ switch ( modId ) {
+
+ case 'whiteBalance':
+ comp.fromJSON( { params: { temperature: params.temperature ?? 0, tint: params.tint ?? 0 } } );
+ break;
+
+ case 'exposure':
+ comp.fromJSON( { params: { exposure: params.exposure ?? 0 } } );
+ break;
+
+ case 'brightness':
+ comp.fromJSON( { params: { brightness: params.brightness ?? 0 } } );
+ break;
+
+ case 'hue':
+ comp.fromJSON( { params: { hueShift: params.hueShift ?? 0 } } );
+ break;
+
+ case 'lift':
+ if ( params.lift ) comp.fromJSON( { params: params.lift } );
+ else comp.reset();
+ break;
+
+ case 'gammaBal':
+ if ( params.gammaBal ) comp.fromJSON( { params: params.gammaBal } );
+ else comp.reset();
+ break;
+
+ case 'gain':
+ if ( params.gain ) comp.fromJSON( { params: params.gain } );
+ else comp.reset();
+ break;
+
+ case 'offset':
+ if ( params.offset ) comp.fromJSON( { params: params.offset } );
+ else comp.reset();
+ break;
+
+ case 'curves':
+ if ( params.curves ) comp.fromJSON( { params: { curves: params.curves } } );
+ else comp.reset();
+ break;
+
+ case 'contrast':
+ comp.fromJSON( { params: { contrast: params.contrast ?? 1.0, contrastPivot: params.contrastPivot ?? 0.5 } } );
+ break;
+
+ case 'saturation':
+ comp.fromJSON( { params: { saturation: params.saturation ?? 1.0 } } );
+ break;
+
+ case 'vibrance':
+ comp.fromJSON( { params: { vibrance: params.vibrance ?? 0 } } );
+ break;
+
+ }
+
+ }
+
+ load( data ) {
+
+ if ( ! data ) return;
+
+ let json = data;
+ if ( typeof json === 'string' ) {
+
+ try {
+
+ json = JSON.parse( json );
+
+ } catch ( err ) {
+
+ console.error( 'ColorGrading.load: Invalid JSON data', err );
+ return;
+
+ }
+
+ }
+
+ if ( typeof json !== 'object' || json === null ) return;
+
+ const params = json.params ? json.params : ( json.temperature !== undefined || json.contrast !== undefined || json.curves !== undefined ? json : null );
+
+ if ( typeof json.lutSize === 'number' ) {
+
+ this.lutSize = json.lutSize;
+ if ( this.lutSizeSelect ) {
+
+ this.lutSizeSelect.value = String( this.lutSize );
+
+ }
+
+ }
+
+ if ( typeof json.selectedToneMapping === 'number' ) {
+
+ this.selectedToneMapping = json.selectedToneMapping;
+ const rendererComp = this.modulesMap.get( 'renderer' );
+ if ( rendererComp ) {
+
+ rendererComp.fromJSON( { params: { toneMapping: this.selectedToneMapping } } );
+
+ }
+
+ }
+
+ if ( typeof json.sceneGradingEnabled === 'boolean' ) {
+
+ this.sceneGradingEnabled = json.sceneGradingEnabled;
+ this.updateGradingBtnState();
+ this._toggleSceneGrading( this.sceneGradingEnabled );
+
+ }
+
+ if ( Array.isArray( json.pipelineOrder ) && json.pipelineOrder.length > 0 ) {
+
+ this.pipelineOrder = [ ...json.pipelineOrder ];
+
+ }
+
+ if ( json.modules && typeof json.modules === 'object' ) {
+
+ this.modulesMap.forEach( ( comp, id ) => {
+
+ if ( id !== 'renderer' && id !== 'output' && ! this.pipelineOrder.includes( id ) ) {
+
+ if ( comp.domElement ) comp.domElement.remove();
+ this.modulesMap.delete( id );
+ this.cardsMap.delete( id );
+
+ }
+
+ } );
+
+ Object.keys( json.modules ).forEach( id => {
+
+ const mData = json.modules[ id ];
+ if ( id === 'renderer' ) {
+
+ const rendererComp = this.modulesMap.get( 'renderer' );
+ if ( rendererComp ) {
+
+ rendererComp.fromJSON( { params: mData.params } );
+
+ }
+
+ } else if ( this.modulesMap.has( id ) ) {
+
+ this.modulesMap.get( id ).fromJSON( { params: mData.params } );
+
+ } else {
+
+ if ( mData.type === 'importedCube' ) {
+
+ const comp = new ImportedCubeModule(
+ id,
+ mData.params,
+ () => this._onParamChange(),
+ ( removeId ) => this._removeCard( removeId )
+ );
+
+ this.modulesMap.set( id, comp );
+ this.cardsMap.set( id, comp.domElement );
+ this._setupCardDragAndDrop( comp.domElement, id );
+
+ } else {
+
+ const targetType = mData.mode || mData.type;
+ this._addCardAt( targetType, this.pipelineOrder.length, mData.params, id );
+
+ }
+
+ }
+
+ } );
+
+ } else if ( params ) {
+
+ const isWBActive = ( params.temperature !== undefined && params.temperature !== 0 ) || ( params.tint !== undefined && params.tint !== 0 );
+ const isExpActive = params.exposure !== undefined && params.exposure !== 0;
+ const isBrightActive = params.brightness !== undefined && params.brightness !== 0;
+ const isHueActive = params.hueShift !== undefined && params.hueShift !== 0;
+ const isLiftActive = params.lift && ( params.lift.r !== 0 || params.lift.g !== 0 || params.lift.b !== 0 );
+ const isGammaActive = params.gammaBal && ( params.gammaBal.r !== 0 || params.gammaBal.g !== 0 || params.gammaBal.b !== 0 );
+ const isGainActive = params.gain && ( params.gain.r !== 0 || params.gain.g !== 0 || params.gain.b !== 0 );
+ const isOffsetActive = params.offset && ( params.offset.r !== 0 || params.offset.g !== 0 || params.offset.b !== 0 );
+ const isCurvesActive = params.curves && Array.isArray( params.curves.rgb ) && ( params.curves.rgb.length > 2 || params.curves.rgb.some( p => p.x !== p.y ) || ( params.curves.red && params.curves.red.some( p => p.x !== p.y ) ) || ( params.curves.blue && params.curves.blue.some( p => p.x !== p.y ) ) );
+ const isContrastActive = params.contrast !== undefined && params.contrast !== 1.0;
+ const isSatActive = params.saturation !== undefined && params.saturation !== 1.0;
+ const isVibActive = params.vibrance !== undefined && params.vibrance !== 0;
+
+ const isAllNeutral = ! isWBActive && ! isExpActive && ! isBrightActive && ! isHueActive && ! isLiftActive && ! isGammaActive && ! isGainActive && ! isOffsetActive && ! isCurvesActive && ! isContrastActive && ! isSatActive && ! isVibActive;
+
+ let targetOrder = [];
+
+ if ( isAllNeutral ) {
+
+ targetOrder = [ ...DEFAULT_PIPELINE_ORDER ];
+
+ } else {
+
+ targetOrder = [ 'renderer' ];
+ if ( isWBActive ) targetOrder.push( 'whiteBalance' );
+ if ( isExpActive ) targetOrder.push( 'exposure' );
+ if ( isBrightActive ) targetOrder.push( 'brightness' );
+ if ( isHueActive ) targetOrder.push( 'hue' );
+ if ( isLiftActive ) targetOrder.push( 'lift' );
+ if ( isGammaActive ) targetOrder.push( 'gammaBal' );
+ if ( isGainActive ) targetOrder.push( 'gain' );
+ if ( isOffsetActive ) targetOrder.push( 'offset' );
+ if ( isCurvesActive ) targetOrder.push( 'curves' );
+ if ( isContrastActive ) targetOrder.push( 'contrast' );
+ if ( isSatActive ) targetOrder.push( 'saturation' );
+ if ( isVibActive ) targetOrder.push( 'vibrance' );
+ targetOrder.push( 'output' );
+
+ }
+
+ // Remove cards not used by this preset (except renderer)
+ this.modulesMap.forEach( ( comp, id ) => {
+
+ if ( id !== 'renderer' && id !== 'output' && ! targetOrder.includes( id ) ) {
+
+ if ( comp && comp.domElement ) comp.domElement.remove();
+ this.modulesMap.delete( id );
+ this.cardsMap.delete( id );
+
+ }
+
+ } );
+
+ // Ensure target cards exist and update their values
+ targetOrder.forEach( ( modId ) => {
+
+ if ( modId === 'renderer' ) {
+
+ const rendererComp = this.modulesMap.get( 'renderer' );
+ if ( rendererComp && ( params.rendererToneMapping !== undefined || params.rendererExposure !== undefined ) ) {
+
+ const toneMapping = params.rendererToneMapping !== undefined ? params.rendererToneMapping : rendererComp.params.toneMapping;
+ const exposure = params.rendererExposure !== undefined ? params.rendererExposure : rendererComp.params.exposure;
+ rendererComp.fromJSON( { params: { toneMapping, exposure } } );
+
+ }
+
+ } else if ( this.modulesMap.has( modId ) ) {
+
+ this._applyParamsToModule( modId, params );
+
+ } else {
+
+ this._addCardAt( modId, targetOrder.length, params, modId );
+
+ }
+
+ } );
+
+ this.pipelineOrder = targetOrder;
+
+ if ( params.importedCubes ) {
+
+ Object.keys( params.importedCubes ).forEach( modId => {
+
+ const cubeData = params.importedCubes[ modId ];
+ const comp = this.modulesMap.get( modId );
+
+ if ( ! comp ) {
+
+ const newComp = new ImportedCubeModule(
+ modId,
+ cubeData,
+ () => this._onParamChange(),
+ ( removeId ) => this._removeCard( removeId )
+ );
+
+ this.modulesMap.set( modId, newComp );
+ this.cardsMap.set( modId, newComp.domElement );
+ this._setupCardDragAndDrop( newComp.domElement, modId );
+
+ } else {
+
+ comp.fromJSON( { params: cubeData } );
+
+ }
+
+ } );
+
+ }
+
+ }
+
+ this._renderCardsFlow();
+ this._onParamChange();
+
+ }
+
+ _exportJsonFile() {
+
+ const baseName = this._getExportFileName();
+ const configStr = JSON.stringify( this.toJSON(), null, 2 );
+ const blob = new Blob( [ configStr ], { type: 'application/json' } );
+ const url = URL.createObjectURL( blob );
+ const link = document.createElement( 'a' );
+ link.href = url;
+ link.download = `${baseName}.json`;
+ link.click();
+ URL.revokeObjectURL( url );
+
+ }
+
+ _importJsonFile() {
+
+ const fileInput = document.createElement( 'input' );
+ fileInput.type = 'file';
+ fileInput.accept = '.json';
+
+ fileInput.onchange = ( e ) => {
+
+ const file = e.target.files[ 0 ];
+ if ( ! file ) return;
+
+ const reader = new FileReader();
+ reader.onload = ( evt ) => {
+
+ try {
+
+ const json = JSON.parse( evt.target.result );
+ this.load( json );
+
+ } catch ( err ) {
+
+ alert( 'Error reading JSON config file: ' + err.message );
+
+ }
+
+ };
+
+ reader.readAsText( file );
+
+ };
+
+ fileInput.click();
+
+ }
+
+ _createImportedCubeCard( modId ) {
+
+ const cubeInfo = this.params.importedCubes ? this.params.importedCubes[ modId ] : null;
+ const comp = new ImportedCubeModule(
+ modId,
+ cubeInfo || {},
+ () => this._onParamChange(),
+ ( removeId ) => this._removeCard( removeId )
+ );
+
+ this.modulesMap.set( modId, comp );
+ this.cardsMap.set( modId, comp.domElement );
+
+ return comp.domElement;
+
+ }
+
+ _removeImportedCubeCard( modId ) {
+
+ this._removeCard( modId );
+
+ }
+
+ _importCubeFileAt( insertIndex ) {
+
+ const fileInput = document.createElement( 'input' );
+ fileInput.type = 'file';
+ fileInput.accept = '.cube';
+
+ fileInput.onchange = ( e ) => {
+
+ const file = e.target.files[ 0 ];
+ if ( ! file ) return;
+
+ const reader = new FileReader();
+ reader.onload = ( evt ) => {
+
+ try {
+
+ const text = evt.target.result;
+ const parsed = parseCubeFormat( text );
+ const cubeData = parsed.dataLines || parsed.data;
+ if ( ! cubeData || cubeData.length === 0 ) {
+
+ alert( 'Invalid or empty .CUBE file.' );
+ return;
+
+ }
+
+ const modId = 'importedCube_' + Date.now() + '_' + Math.floor( Math.random() * 1000 );
+
+ const fileNameNoExt = file.name.replace( /\.cube$/i, '' );
+ const parsedTitle = parsed.title ? parsed.title.trim() : '';
+ const isGenericTitle = ! parsedTitle ||
+ parsedTitle.toLowerCase() === 'untitled' ||
+ parsedTitle.toLowerCase() === 'imported lut';
+
+ const titleName = isGenericTitle ? fileNameNoExt : parsedTitle;
+
+ const cubeInfo = {
+ title: titleName,
+ size: parsed.size || 32,
+ dataLines: cubeData,
+ weight: 1.0
+ };
+
+ const comp = new ImportedCubeModule(
+ modId,
+ cubeInfo,
+ () => this._onParamChange(),
+ ( removeId ) => this._removeCard( removeId )
+ );
+
+ this.modulesMap.set( modId, comp );
+ this.cardsMap.set( modId, comp.domElement );
+
+ const targetIdx = ( typeof insertIndex === 'number' ) ? Math.max( 0, Math.min( this.pipelineOrder.length, insertIndex ) ) : this.pipelineOrder.length;
+ this.pipelineOrder.splice( targetIdx, 0, modId );
+
+ this._setupCardDragAndDrop( comp.domElement, modId );
+
+ this._renderCardsFlow();
+ this._onParamChange();
+
+ if ( comp.domElement ) {
+
+ setTimeout( () => {
+
+ comp.domElement.scrollIntoView( { behavior: 'smooth', block: 'nearest', inline: 'center' } );
+
+ }, 50 );
+
+ }
+
+ } catch ( err ) {
+
+ alert( 'Error reading .CUBE file: ' + err.message );
+
+ }
+
+ };
+
+ reader.readAsText( file );
+
+ };
+
+ fileInput.click();
+
+ }
+
+ _importCubeFile() {
+
+ this._importCubeFileAt( 1 );
+
+ }
+
+ init( inspector ) {
+
+ super.init( inspector );
+
+ const renderer = inspector.getRenderer();
+ const rendererComp = this.modulesMap.get( 'renderer' );
+
+ if ( renderer && rendererComp ) {
+
+ const defaultTM = ( renderer.toneMapping !== undefined ) ? renderer.toneMapping : NoToneMapping;
+ const defaultExp = ( renderer.toneMappingExposure !== undefined ) ? renderer.toneMappingExposure : 1.0;
+
+ rendererComp.defaultToneMapping = defaultTM;
+ rendererComp.defaultExposure = defaultExp;
+ rendererComp.params.toneMapping = defaultTM;
+ rendererComp.params.exposure = defaultExp;
+ rendererComp.updateUI();
+
+ }
+
+ this.onOrientationChange();
+ this.onLayoutChange();
+
+ }
+
+ setActive( isActive ) {
+
+ super.setActive( isActive );
+
+ if ( isActive ) {
+
+ this.onOrientationChange();
+ this.onLayoutChange();
+
+ }
+
+ }
+
+ onOrientationChange( event ) {
+
+ const isVert = this.inspector.isVertical() ||
+ ( event && event.isVertical ) ||
+ ( this.content.clientWidth < 550 );
+
+ this.viewContainer.classList.toggle( 'is-vertical', isVert );
+ this.cardsRow.classList.toggle( 'is-vertical', isVert );
+
+ this.onLayoutChange();
+
+ }
+
+ onLayoutChange() {
+
+ const width = this.content.clientWidth;
+ this.dockContainer.classList.toggle( 'is-compact', width > 0 && width < 400 );
+
+ }
+
+ update( inspector ) {
+
+ super.update( inspector );
+
+ if ( this.sceneGradingEnabled && ( this._lutNeedsUpdate || this._lutSizeChanged ) ) {
+
+ this._updateLiveGrading( inspector );
+
+ }
+
+ }
+
+ _getRenderPipeline( renderer ) {
+
+ return _rendererPipelines.get( renderer );
+
+ }
+
+ _toggleSceneGrading( enable ) {
+
+ this.sceneGradingEnabled = enable;
+
+ if ( enable ) {
+
+ this._lutNeedsUpdate = true;
+
+ this._applyLiveGrading();
+
+ } else {
+
+ this._removeLiveGrading();
+
+ }
+
+ }
+
+ _updateLiveGrading( /* inspector */ ) {
+
+ if ( this.sceneGradingEnabled ) {
+
+ this._applyLiveGrading();
+
+ }
+
+ }
+
+ _applyLiveGrading() {
+
+ if ( ! this.lutTexture || this._lutSizeChanged || this._lutNeedsUpdate ) {
+
+ const buffer = ( this.lutTexture && this.lutTexture.image && ! this._lutSizeChanged ) ? this.lutTexture.image.data : null;
+ const data = this.generate3DLUTData( this.lutSize, buffer );
+
+ if ( ! this.lutTexture || this._lutSizeChanged ) {
+
+ if ( this.lutTexture ) this.lutTexture.dispose();
+
+ this.lutTexture = new Data3DTexture( data, this.lutSize, this.lutSize, this.lutSize );
+ this.lutTexture.format = RGBAFormat;
+ this.lutTexture.type = FloatType;
+ this.lutTexture.minFilter = LinearFilter;
+ this.lutTexture.magFilter = LinearFilter;
+ this.lutTexture.unpackAlignment = 1;
+ this._lutSizeChanged = false;
+
+ }
+
+ this.lutTexture.needsUpdate = true;
+ this._lutNeedsUpdate = false;
+
+ }
+
+ const renderer = this.inspector.getRenderer();
+ let renderPipeline = _rendererPipelines.get( renderer );
+
+ const primaryPass = this.inspector.getPrimaryPass();
+ const scene = primaryPass ? primaryPass.scene : null;
+ const camera = primaryPass ? primaryPass.camera : null;
+
+ if ( ! renderPipeline && scene && camera ) {
+
+ this._createdPipeline = new RenderPipeline( renderer, pass( scene, camera ) );
+ renderPipeline = this._createdPipeline;
+ _rendererPipelines.set( renderer, renderPipeline );
+
+ }
+
+ if ( ! renderPipeline ) return;
+
+ if ( this._createdPipeline && ! this._origRendererRender ) {
+
+ this._origRendererRender = renderer.render;
+ let isRendering = false;
+
+ renderer.render = ( sceneArg, cameraArg, ...rest ) => {
+
+ if ( this.sceneGradingEnabled && ! isRendering ) {
+
+ const activePipeline = _rendererPipelines.get( renderer );
+
+ if ( activePipeline ) {
+
+ isRendering = true;
+
+ try {
+
+ activePipeline.render();
+ return;
+
+ } finally {
+
+ isRendering = false;
+
+ }
+
+ }
+
+ }
+
+ return this._origRendererRender.call( renderer, sceneArg, cameraArg, ...rest );
+
+ };
+
+ }
+
+ if ( ! this._originalPipelineSettings ) {
+
+ this._originalPipelineSettings = {
+ outputNode: renderPipeline.outputNode,
+ outputColorTransform: renderPipeline.outputColorTransform,
+ rendererToneMapping: renderer.toneMapping
+ };
+
+ if ( renderer.toneMapping !== undefined ) {
+
+ this.selectedToneMapping = renderer.toneMapping;
+ const rendererComp = this.modulesMap.get( 'renderer' );
+ if ( rendererComp ) {
+
+ rendererComp.fromJSON( { params: { toneMapping: this.selectedToneMapping } } );
+
+ }
+
+ }
+
+ }
+
+ renderer.toneMapping = this.selectedToneMapping;
+ renderPipeline.outputColorTransform = false;
+
+ let inputNode = this._originalPipelineSettings.outputNode;
+
+ if ( ! inputNode && scene && camera ) {
+
+ inputNode = pass( scene, camera );
+
+ }
+
+ if ( inputNode ) {
+
+ const outputPass = renderOutput( inputNode, this.selectedToneMapping );
+ this.lutPassNode = lut3D( outputPass, texture3D( this.lutTexture ), this.lutSize, 1.0 );
+
+ renderPipeline.outputNode = this.lutPassNode;
+ renderPipeline.needsUpdate = true;
+
+ }
+
+ }
+
+ _removeLiveGrading() {
+
+ const renderer = this.inspector.getRenderer();
+ const renderPipeline = _rendererPipelines.get( renderer );
+
+ if ( this._originalPipelineSettings ) {
+
+ if ( renderPipeline ) {
+
+ renderPipeline.outputNode = this._originalPipelineSettings.outputNode;
+ renderPipeline.outputColorTransform = this._originalPipelineSettings.outputColorTransform;
+ renderPipeline.needsUpdate = true;
+
+ }
+
+ if ( renderer && this._originalPipelineSettings.rendererToneMapping !== undefined ) {
+
+ renderer.toneMapping = this._originalPipelineSettings.rendererToneMapping;
+
+ }
+
+ this._originalPipelineSettings = null;
+
+ }
+
+ if ( renderer && this._origRendererRender ) {
+
+ renderer.render = this._origRendererRender;
+ this._origRendererRender = null;
+
+ }
+
+ if ( this._createdPipeline ) {
+
+ if ( renderer ) {
+
+ _rendererPipelines.delete( renderer );
+
+ }
+
+ this._createdPipeline.dispose();
+ this._createdPipeline = null;
+
+ }
+
+ this.lutPassNode = null;
+
+ if ( this.lutTexture ) {
+
+ this.lutTexture.dispose();
+ this.lutTexture = null;
+
+ }
+
+ }
+
+ dispose() {
+
+ this._removeLiveGrading();
+
+ if ( this._cardsResizeObserver ) {
+
+ this._cardsResizeObserver.disconnect();
+ this._cardsResizeObserver = null;
+
+ }
+
+ if ( this._windowResizeHandler ) {
+
+ window.removeEventListener( 'resize', this._windowResizeHandler );
+ this._windowResizeHandler = null;
+
+ }
+
+ this.modulesMap.clear();
+ this.cardsMap.clear();
+
+ super.dispose();
+
+ }
+
+}
diff --git a/examples/jsm/inspector/extensions/color-grading/ColorWheel.js b/examples/jsm/inspector/extensions/color-grading/ColorWheel.js
new file mode 100644
index 00000000000000..b2bd1423abea3c
--- /dev/null
+++ b/examples/jsm/inspector/extensions/color-grading/ColorWheel.js
@@ -0,0 +1,352 @@
+/**
+ * ColorWheel.js - DaVinci Resolve style interactive Color Wheel control widget
+ */
+
+export class ColorWheel {
+
+ constructor( title, initialValues = { r: 0, g: 0, b: 0 }, onChange, onRemove = null ) {
+
+ this.title = title;
+ this.values = { r: initialValues.r || 0, g: initialValues.g || 0, b: initialValues.b || 0 };
+ this.onChange = onChange;
+ this.onRemove = onRemove;
+
+ this.domElement = document.createElement( 'div' );
+ this.domElement.className = 'lut-card';
+
+ this._buildUI();
+ this._initEvents();
+
+ // Deferred initial draw
+ requestAnimationFrame( () => this.draw() );
+
+ }
+
+ _buildUI() {
+
+ // Header title + reset + remove
+ const header = document.createElement( 'div' );
+ header.className = 'lut-card-header';
+
+ const _createSvgIcon = ( svgPath, width = 11, height = 11, strokeWidth = 2.5 ) => {
+
+ const ns = 'http://www.w3.org/2000/svg';
+ const svg = document.createElementNS( ns, 'svg' );
+ svg.setAttribute( 'width', String( width ) );
+ svg.setAttribute( 'height', String( height ) );
+ svg.setAttribute( 'viewBox', '0 0 24 24' );
+ svg.setAttribute( 'fill', 'none' );
+ svg.setAttribute( 'stroke', 'currentColor' );
+ svg.setAttribute( 'stroke-width', String( strokeWidth ) );
+ svg.setAttribute( 'stroke-linecap', 'round' );
+ svg.setAttribute( 'stroke-linejoin', 'round' );
+ svg.style.display = 'block';
+ svg.innerHTML = svgPath;
+ return svg;
+
+ };
+
+ // Left Drag Handle + Reset Button
+ const dragHandle = document.createElement( 'div' );
+ dragHandle.className = 'lut-card-drag-handle';
+
+ const resetBtn = document.createElement( 'button' );
+ resetBtn.className = 'card-reset-btn lut-card-reset-btn';
+ resetBtn.appendChild( _createSvgIcon( '', 13, 13, 2.5 ) );
+ resetBtn.title = 'Reset Wheel';
+ resetBtn.onclick = ( e ) => {
+
+ e.stopPropagation();
+ this.setValues( 0, 0, 0 );
+ if ( typeof this.onChange === 'function' ) this.onChange( this.values );
+
+ };
+
+ dragHandle.appendChild( resetBtn );
+ header.appendChild( dragHandle );
+
+ const titleLabel = document.createElement( 'span' );
+ titleLabel.className = 'lut-card-title';
+ titleLabel.textContent = this.title;
+ header.appendChild( titleLabel );
+
+ // Right Remove Button (where reset button was)
+ if ( this.onRemove ) {
+
+ const removeBtn = document.createElement( 'button' );
+ removeBtn.className = 'lut-card-remove-btn';
+ removeBtn.appendChild( _createSvgIcon( '', 13, 13, 2.5 ) );
+ removeBtn.title = 'Remove Wheel Card';
+ removeBtn.onclick = ( e ) => {
+
+ e.stopPropagation();
+ this.onRemove();
+
+ };
+
+ header.appendChild( removeBtn );
+
+ }
+
+ this.domElement.appendChild( header );
+
+ // Wheel Canvas
+ this.canvas = document.createElement( 'canvas' );
+ this.canvas.width = 110;
+ this.canvas.height = 110;
+ this.canvas.className = 'lut-wheel-canvas';
+ this.ctx = this.canvas.getContext( '2d' );
+ this.domElement.appendChild( this.canvas );
+
+ // Master Y (Luminance) Slider directly below canvas wheel
+ const ySlider = document.createElement( 'input' );
+ ySlider.type = 'range';
+ ySlider.className = 'inspector-slider';
+ ySlider.style.cssText = 'width: 100%; margin: 4px 0 2px 0 !important;';
+ ySlider.min = '-0.5';
+ ySlider.max = '0.5';
+ ySlider.step = '0.01';
+ ySlider.value = ( this.values && typeof this.values.y === 'number' ) ? this.values.y : 0;
+ this.ySlider = ySlider;
+
+ ySlider.oninput = () => {
+
+ const val = parseFloat( ySlider.value );
+ if ( this.inputs.y ) this.inputs.y.value = val.toFixed( 2 );
+ this.values.y = val;
+ if ( typeof this.onChange === 'function' ) this.onChange( this.values );
+
+ };
+
+ this.domElement.appendChild( ySlider );
+
+ // Bottom Inputs Row (Y, R, G, B)
+ const inputsRow = document.createElement( 'div' );
+ inputsRow.className = 'lut-wheel-inputs-row';
+
+ this.inputs = {};
+
+ [
+ { key: 'y', label: 'Y' },
+ { key: 'r', label: 'R' },
+ { key: 'g', label: 'G' },
+ { key: 'b', label: 'B' }
+ ].forEach( ch => {
+
+ const box = document.createElement( 'div' );
+ box.className = 'lut-wheel-input-box';
+
+ const input = document.createElement( 'input' );
+ input.type = 'number';
+ input.step = '0.01';
+ input.min = '-0.5';
+ input.max = '0.5';
+ const valNum = ( this.values && typeof this.values[ ch.key ] === 'number' ) ? this.values[ ch.key ] : 0;
+ input.value = valNum.toFixed( 2 );
+ input.className = `lut-rgb-input lut-rgb-input-${ch.key}`;
+
+ input.onchange = () => {
+
+ const val = Math.max( - 0.5, Math.min( 0.5, parseFloat( input.value ) || 0 ) );
+ this.values[ ch.key ] = val;
+ input.value = val.toFixed( 2 );
+ if ( ch.key === 'y' && this.ySlider ) {
+
+ this.ySlider.value = val;
+
+ } else {
+
+ this.draw();
+
+ }
+
+ if ( typeof this.onChange === 'function' ) this.onChange( this.values );
+
+ };
+
+ this.inputs[ ch.key ] = input;
+ box.appendChild( input );
+ inputsRow.appendChild( box );
+
+ } );
+
+ this.domElement.appendChild( inputsRow );
+
+ }
+
+ setValues( r, g, b, y = undefined ) {
+
+ this.values.r = r;
+ this.values.g = g;
+ this.values.b = b;
+
+ if ( y !== undefined ) {
+
+ this.values.y = y;
+ if ( this.inputs.y ) this.inputs.y.value = y.toFixed( 2 );
+ if ( this.ySlider ) this.ySlider.value = y;
+
+ }
+
+ if ( this.inputs.r ) this.inputs.r.value = r.toFixed( 2 );
+ if ( this.inputs.g ) this.inputs.g.value = g.toFixed( 2 );
+ if ( this.inputs.b ) this.inputs.b.value = b.toFixed( 2 );
+
+ this.draw();
+
+ }
+
+ draw() {
+
+ if ( ! this.ctx ) return;
+
+ const ctx = this.ctx;
+ const w = this.canvas.width;
+ const h = this.canvas.height;
+ const cx = w / 2;
+ const cy = h / 2;
+ const outerR = w / 2 - 2;
+ const innerR = outerR - 12;
+
+ ctx.clearRect( 0, 0, w, h );
+
+ // 1. Draw outer hue spectrum ring
+ for ( let angle = 0; angle < 360; angle += 2 ) {
+
+ const startAngle = ( angle - 1 ) * Math.PI / 180;
+ const endAngle = ( angle + 2 ) * Math.PI / 180;
+
+ ctx.beginPath();
+ ctx.arc( cx, cy, outerR, startAngle, endAngle );
+ ctx.arc( cx, cy, innerR, endAngle, startAngle, true );
+ ctx.closePath();
+
+ ctx.fillStyle = `hsl(${angle}, 100%, 50%)`;
+ ctx.fill();
+
+ }
+
+ // 2. Inner disc background
+ ctx.beginPath();
+ ctx.arc( cx, cy, innerR - 1, 0, Math.PI * 2 );
+ ctx.fillStyle = '#101014';
+ ctx.fill();
+ ctx.strokeStyle = '#2a2a36';
+ ctx.lineWidth = 1;
+ ctx.stroke();
+
+ // 3. Crosshairs
+ ctx.strokeStyle = '#252530';
+ ctx.lineWidth = 1;
+ ctx.beginPath();
+ ctx.moveTo( cx - innerR + 4, cy );
+ ctx.lineTo( cx + innerR - 4, cy );
+ ctx.moveTo( cx, cy - innerR + 4 );
+ ctx.lineTo( cx, cy + innerR - 4 );
+ ctx.stroke();
+
+ // 4. Calculate handle position from RGB offset values
+ // R at 0 rad (right), G at 2.094 rad (120 deg down-left), B at 4.188 rad (240 deg up-left)
+ const rx = this.values.r;
+ const gx = this.values.g;
+ const bx = this.values.b;
+
+ // Convert RGB balance to 2D displacement vector (Canvas space: X right, Y down)
+ const vx = rx - 0.5 * gx - 0.5 * bx;
+ const vy = 0.866025 * ( gx - bx );
+
+ const maxDist = innerR - 6;
+ const hx = cx + Math.max( - maxDist, Math.min( maxDist, vx * maxDist * 2 ) );
+ const hy = cy + Math.max( - maxDist, Math.min( maxDist, vy * maxDist * 2 ) );
+
+ // 5. Draw handle
+ ctx.beginPath();
+ ctx.arc( hx, hy, 5, 0, Math.PI * 2 );
+ ctx.fillStyle = '#ffffff';
+ ctx.fill();
+ ctx.strokeStyle = '#00aaff';
+ ctx.lineWidth = 2;
+ ctx.stroke();
+
+ }
+
+ _initEvents() {
+
+ let isDragging = false;
+
+ const handlePointer = ( e ) => {
+
+ const rect = this.canvas.getBoundingClientRect();
+ const clientX = e.touches ? e.touches[ 0 ].clientX : e.clientX;
+ const clientY = e.touches ? e.touches[ 0 ].clientY : e.clientY;
+
+ const cx = rect.width / 2;
+ const cy = rect.height / 2;
+ const dx = ( clientX - rect.left ) - cx;
+ const dy = ( clientY - rect.top ) - cy;
+
+ const innerR = ( rect.width / 2 ) - 14;
+ const dist = Math.sqrt( dx * dx + dy * dy );
+ const clampedDist = Math.min( dist, innerR );
+ const normDist = clampedDist / innerR;
+
+ let angle = Math.atan2( dy, dx );
+ if ( angle < 0 ) angle += Math.PI * 2;
+
+ // Convert polar position to RGB balance offsets (-0.5 .. 0.5)
+ const magnitude = normDist * 0.5;
+
+ // Project angle onto R (0 deg), G (120 deg), B (240 deg)
+ const rVal = Math.cos( angle ) * magnitude;
+ const gVal = Math.cos( angle - 2.094395 ) * magnitude;
+ const bVal = Math.cos( angle - 4.18879 ) * magnitude;
+
+ this.setValues( rVal, gVal, bVal );
+
+ if ( typeof this.onChange === 'function' ) {
+
+ this.onChange( this.values );
+
+ }
+
+ };
+
+ this.canvas.addEventListener( 'pointerdown', ( e ) => {
+
+ isDragging = true;
+ this.canvas.setPointerCapture( e.pointerId );
+ handlePointer( e );
+
+ } );
+
+ this.canvas.addEventListener( 'pointermove', ( e ) => {
+
+ if ( isDragging ) {
+
+ handlePointer( e );
+
+ }
+
+ } );
+
+ const stopDrag = ( e ) => {
+
+ if ( isDragging ) {
+
+ isDragging = false;
+ try {
+
+ this.canvas.releasePointerCapture( e.pointerId );
+
+ } catch ( _err ) { /* ignore */ }
+
+ }
+
+ };
+
+ this.canvas.addEventListener( 'pointerup', stopDrag );
+ this.canvas.addEventListener( 'pointercancel', stopDrag );
+
+ }
+
+}
diff --git a/examples/jsm/inspector/extensions/color-grading/CurveEditor.js b/examples/jsm/inspector/extensions/color-grading/CurveEditor.js
new file mode 100644
index 00000000000000..5ee749e394096f
--- /dev/null
+++ b/examples/jsm/inspector/extensions/color-grading/CurveEditor.js
@@ -0,0 +1,599 @@
+import { evaluateSpline } from './LUTMath.js';
+
+export class CurveEditor {
+
+ constructor( options = {} ) {
+
+ this.curves = options.curves || {
+ rgb: [ { x: 0, y: 0 }, { x: 1, y: 1 } ],
+ red: [ { x: 0, y: 0 }, { x: 1, y: 1 } ],
+ green: [ { x: 0, y: 0 }, { x: 1, y: 1 } ],
+ blue: [ { x: 0, y: 0 }, { x: 1, y: 1 } ]
+ };
+
+ this.activeChannel = options.activeChannel || 'rgb';
+ this.onChange = options.onChange || null;
+
+ this.selectedIndex = - 1;
+ this.isDragging = false;
+ this.pointRadius = 4;
+
+ this.domElement = document.createElement( 'div' );
+ this.domElement.className = 'curve-editor-container';
+ this.domElement.style.cssText = 'display: flex; flex-direction: column; gap: 4px; width: 100%; user-select: none; background: transparent; border: none; padding: 0; box-sizing: border-box;';
+
+ this._buildUI();
+ this._initEvents();
+
+ if ( typeof ResizeObserver !== 'undefined' ) {
+
+ const ro = new ResizeObserver( () => this._onResize() );
+ ro.observe( this.domElement );
+
+ }
+
+ requestAnimationFrame( () => {
+
+ this._onResize();
+
+ } );
+
+ }
+
+ _buildUI() {
+
+ // Header toolbar (Channel selector)
+ const toolbar = document.createElement( 'div' );
+ toolbar.style.cssText = 'display: flex; justify-content: center; align-items: center; width: 100%; gap: 4px;';
+
+ // Channel selector tabs
+ const channelGroup = document.createElement( 'div' );
+ channelGroup.style.cssText = 'display: flex; gap: 3px; background: #0a0a0f; padding: 2px; border-radius: 4px; border: 1px solid #222230;';
+
+ const channels = [
+ { id: 'rgb', label: 'RGB', color: '#e0e0e0' },
+ { id: 'red', label: 'R', color: '#ff4d4d' },
+ { id: 'green', label: 'G', color: '#4dff4d' },
+ { id: 'blue', label: 'B', color: '#4d88ff' }
+ ];
+
+ this.channelBtns = {};
+
+ channels.forEach( ch => {
+
+ const btn = document.createElement( 'button' );
+ btn.textContent = ch.label;
+ btn.style.cssText = `padding: 2px 8px; font-size: 11px; font-weight: 700; border-radius: 3px; border: 1px solid transparent; cursor: pointer; color: ${ch.color}; background: transparent; transition: all 0.15s;`;
+ btn.onclick = () => this.setChannel( ch.id );
+
+ this.channelBtns[ ch.id ] = btn;
+ channelGroup.appendChild( btn );
+
+ } );
+
+ toolbar.appendChild( channelGroup );
+ this.domElement.appendChild( toolbar );
+
+ // Canvas Container
+ this.canvasContainer = document.createElement( 'div' );
+ this.canvasContainer.style.cssText = 'width: 100%; height: 130px; position: relative; background: #0c0c12; border-radius: 6px; overflow: hidden;';
+
+ this.canvas = document.createElement( 'canvas' );
+ this.canvas.setAttribute( 'tabindex', '0' );
+ this.canvas.style.cssText = 'width: 100%; height: 100%; display: block; cursor: crosshair; touch-action: none; outline: none;';
+ this.ctx = this.canvas.getContext( '2d' );
+
+ this.canvasContainer.appendChild( this.canvas );
+ this.domElement.appendChild( this.canvasContainer );
+
+ this.setChannel( this.activeChannel );
+
+ }
+
+ _onResize() {
+
+ if ( ! this.canvasContainer || ! this.canvas ) return;
+
+ const rect = this.canvasContainer.getBoundingClientRect();
+ if ( rect.width === 0 || rect.height === 0 ) return;
+
+ const dpr = window.devicePixelRatio || 1;
+ const newW = Math.round( rect.width * dpr );
+ const newH = Math.round( rect.height * dpr );
+
+ if ( this.canvas.width !== newW || this.canvas.height !== newH ) {
+
+ this.canvas.width = newW;
+ this.canvas.height = newH;
+ this.render();
+
+ }
+
+ }
+
+ setChannel( channelId ) {
+
+ this.activeChannel = channelId;
+ this.selectedIndex = - 1;
+
+ Object.keys( this.channelBtns ).forEach( id => {
+
+ const btn = this.channelBtns[ id ];
+ if ( id === channelId ) {
+
+ btn.style.background = 'rgba(255, 255, 255, 0.12)';
+ btn.style.borderColor = 'rgba(74, 74, 90, 0.6)';
+
+ } else {
+
+ btn.style.background = 'transparent';
+ btn.style.borderColor = 'transparent';
+
+ }
+
+ } );
+
+ this.render();
+
+ }
+
+ setCurves( curves ) {
+
+ if ( curves.rgb ) this.curves.rgb = curves.rgb.map( p => ( { ...p } ) );
+ if ( curves.red ) this.curves.red = curves.red.map( p => ( { ...p } ) );
+ if ( curves.green ) this.curves.green = curves.green.map( p => ( { ...p } ) );
+ if ( curves.blue ) this.curves.blue = curves.blue.map( p => ( { ...p } ) );
+
+ this.selectedIndex = - 1;
+ this.render();
+
+ }
+
+ getCurves() {
+
+ return {
+ rgb: this.curves.rgb.map( p => ( { ...p } ) ),
+ red: this.curves.red.map( p => ( { ...p } ) ),
+ green: this.curves.green.map( p => ( { ...p } ) ),
+ blue: this.curves.blue.map( p => ( { ...p } ) )
+ };
+
+ }
+
+ _getChannelColor( channelId = this.activeChannel ) {
+
+ switch ( channelId ) {
+
+ case 'red': return '#ff3344';
+ case 'green': return '#22dd55';
+ case 'blue': return '#3388ff';
+ default: return '#e0e0e0';
+
+ }
+
+ }
+
+ _normToCanvas( point ) {
+
+ const padL = 28;
+ const padR = 10;
+ const padT = 10;
+ const padB = 16;
+
+ const w = this.canvas.width - padL - padR;
+ const h = this.canvas.height - padT - padB;
+
+ return {
+ x: padL + point.x * w,
+ y: padT + ( 1 - point.y ) * h
+ };
+
+ }
+
+ _canvasToNorm( px, py ) {
+
+ const padL = 28;
+ const padR = 10;
+ const padT = 10;
+ const padB = 16;
+
+ const w = this.canvas.width - padL - padR;
+ const h = this.canvas.height - padT - padB;
+
+ return {
+ x: Math.max( 0, Math.min( 1, ( px - padL ) / w ) ),
+ y: Math.max( 0, Math.min( 1, 1 - ( py - padT ) / h ) )
+ };
+
+ }
+
+ _initEvents() {
+
+ const getPointerPos = ( e ) => {
+
+ const rect = this.canvas.getBoundingClientRect();
+ const clientX = e.touches ? e.touches[ 0 ].clientX : e.clientX;
+ const clientY = e.touches ? e.touches[ 0 ].clientY : e.clientY;
+
+ const scaleX = this.canvas.width / rect.width;
+ const scaleY = this.canvas.height / rect.height;
+
+ return {
+ x: ( clientX - rect.left ) * scaleX,
+ y: ( clientY - rect.top ) * scaleY
+ };
+
+ };
+
+ const findPointIndex = ( cPos ) => {
+
+ const pts = this.curves[ this.activeChannel ];
+ const dpr = window.devicePixelRatio || 1;
+ const hitRadius = 16 * dpr;
+
+ let closestIdx = - 1;
+ let minDist = hitRadius;
+
+ for ( let i = 0; i < pts.length; i ++ ) {
+
+ const ptPos = this._normToCanvas( pts[ i ] );
+ const dx = cPos.x - ptPos.x;
+ const dy = cPos.y - ptPos.y;
+ const dist = Math.sqrt( dx * dx + dy * dy );
+
+ if ( dist <= minDist ) {
+
+ minDist = dist;
+ closestIdx = i;
+
+ }
+
+ }
+
+ return closestIdx;
+
+ };
+
+ const onDown = ( e ) => {
+
+ e.preventDefault();
+ if ( this.canvas && typeof this.canvas.focus === 'function' ) {
+
+ this.canvas.focus();
+
+ }
+
+ const pos = getPointerPos( e );
+ const idx = findPointIndex( pos );
+ const pts = this.curves[ this.activeChannel ];
+
+ // Store snapshot for ESC cancel/revert
+ this._dragStartSnapshot = JSON.parse( JSON.stringify( this.curves ) );
+
+ if ( idx !== - 1 ) {
+
+ this.selectedIndex = idx;
+ this.isDragging = true;
+
+ } else {
+
+ // Single click on empty canvas area adds a new point and selects it
+ const norm = this._canvasToNorm( pos.x, pos.y );
+ pts.push( norm );
+ pts.sort( ( a, b ) => a.x - b.x );
+ this.selectedIndex = pts.findIndex( p => p === norm );
+ this.isDragging = true;
+ this.notifyChange();
+
+ }
+
+ this.render();
+
+ };
+
+ const onMove = ( e ) => {
+
+ if ( ! this.isDragging || this.selectedIndex === - 1 ) return;
+
+ e.preventDefault();
+ const pos = getPointerPos( e );
+ const norm = this._canvasToNorm( pos.x, pos.y );
+ const pts = this.curves[ this.activeChannel ];
+
+ if ( this.selectedIndex === 0 ) {
+
+ pts[ 0 ].y = norm.y;
+
+ } else if ( this.selectedIndex === pts.length - 1 ) {
+
+ pts[ pts.length - 1 ].y = norm.y;
+
+ } else {
+
+ pts[ this.selectedIndex ].x = norm.x;
+ pts[ this.selectedIndex ].y = norm.y;
+ pts.sort( ( a, b ) => a.x - b.x );
+ this.selectedIndex = pts.findIndex( p => p.x === norm.x && p.y === norm.y );
+
+ }
+
+ this.render();
+ this.notifyChange();
+
+ };
+
+ const onUp = () => {
+
+ if ( this.isDragging ) {
+
+ this.isDragging = false;
+
+ }
+
+ };
+
+ const onDblClick = ( e ) => {
+
+ const pos = getPointerPos( e );
+ const idx = findPointIndex( pos );
+ const pts = this.curves[ this.activeChannel ];
+
+ if ( idx > 0 && idx < pts.length - 1 ) {
+
+ // Double click on point deletes it
+ pts.splice( idx, 1 );
+ this.selectedIndex = - 1;
+ this.render();
+ this.notifyChange();
+
+ }
+
+ };
+
+ const onOutsidePointer = ( e ) => {
+
+ if ( this.domElement && ! this.domElement.contains( e.target ) ) {
+
+ if ( this.selectedIndex !== - 1 ) {
+
+ this.selectedIndex = - 1;
+ this.render();
+
+ }
+
+ }
+
+ };
+
+ const onKeyDown = ( e ) => {
+
+ const key = e.key;
+ const isCanvasFocused = document.activeElement === this.canvas || ( this.domElement && this.domElement.contains( document.activeElement ) );
+ const hasSelection = this.selectedIndex !== - 1 || this.isDragging || !! this._dragStartSnapshot;
+
+ if ( ! isCanvasFocused && ! hasSelection ) return;
+
+ const pts = this.curves[ this.activeChannel ];
+
+ if ( key === 'Delete' || key === 'Backspace' || key === 'Del' ) {
+
+ if ( this.selectedIndex > 0 && this.selectedIndex < pts.length - 1 ) {
+
+ e.preventDefault();
+ e.stopPropagation();
+ pts.splice( this.selectedIndex, 1 );
+ this.selectedIndex = - 1;
+ this.isDragging = false;
+ this._dragStartSnapshot = null;
+ this.render();
+ this.notifyChange();
+
+ }
+
+ } else if ( key === 'Escape' || key === 'Esc' ) {
+
+ if ( this._dragStartSnapshot ) {
+
+ e.preventDefault();
+ e.stopPropagation();
+ this.curves = JSON.parse( JSON.stringify( this._dragStartSnapshot ) );
+ this.selectedIndex = - 1;
+ this.isDragging = false;
+ this._dragStartSnapshot = null;
+ this.render();
+ this.notifyChange();
+
+ } else if ( this.selectedIndex !== - 1 ) {
+
+ e.preventDefault();
+ e.stopPropagation();
+ this.selectedIndex = - 1;
+ this.isDragging = false;
+ this.render();
+
+ }
+
+ }
+
+ };
+
+ this.canvas.addEventListener( 'pointerdown', onDown );
+ this.canvas.addEventListener( 'keydown', onKeyDown );
+ window.addEventListener( 'pointermove', onMove );
+ window.addEventListener( 'pointerup', onUp );
+ window.addEventListener( 'pointerdown', onOutsidePointer );
+ window.addEventListener( 'keydown', onKeyDown );
+ this.canvas.addEventListener( 'dblclick', onDblClick );
+
+ }
+
+ notifyChange() {
+
+ if ( typeof this.onChange === 'function' ) {
+
+ this.onChange( this.getCurves() );
+
+ }
+
+ }
+
+ render() {
+
+ if ( ! this.canvas || ! this.ctx ) return;
+
+ const ctx = this.ctx;
+ const w = this.canvas.width;
+ const h = this.canvas.height;
+
+ const padL = 28;
+ const padR = 10;
+ const padT = 10;
+ const padB = 16;
+
+ const graphW = w - padL - padR;
+ const graphH = h - padT - padB;
+
+ // 1. Dark Background Fill
+ ctx.fillStyle = '#0a0a0e';
+ ctx.fillRect( 0, 0, w, h );
+
+ // 2. Normalized Axis Labels (1.0, 0.5, 0.0)
+ ctx.fillStyle = '#66667a';
+ ctx.font = '9px monospace';
+ ctx.textAlign = 'left';
+ ctx.fillText( '1.0', 4, padT + 6 );
+ ctx.fillText( '0.5', 4, padT + graphH / 2 + 3 );
+ ctx.fillText( '0.0', 4, padT + graphH - 1 );
+
+ // 3. Fine Grid Mesh Lines (8x8)
+ ctx.strokeStyle = '#1a1a24';
+ ctx.lineWidth = 1;
+
+ const gridDivs = 8;
+ for ( let i = 0; i <= gridDivs; i ++ ) {
+
+ const gx = padL + ( i / gridDivs ) * graphW;
+ const gy = padT + ( i / gridDivs ) * graphH;
+
+ ctx.beginPath();
+ ctx.moveTo( gx, padT );
+ ctx.lineTo( gx, padT + graphH );
+ ctx.stroke();
+
+ ctx.beginPath();
+ ctx.moveTo( padL, gy );
+ ctx.lineTo( padL + graphW, gy );
+ ctx.stroke();
+
+ }
+
+ // 4. Diagonal Reference Line (y = x)
+ ctx.strokeStyle = '#2d2d3c';
+ ctx.setLineDash( [ 3, 3 ] );
+ ctx.beginPath();
+ ctx.moveTo( padL, padT + graphH );
+ ctx.lineTo( padL + graphW, padT );
+ ctx.stroke();
+ ctx.setLineDash( [] );
+
+ // 5. Theme Gray Border Frame
+ ctx.strokeStyle = 'rgba(74, 74, 90, 0.5)';
+ ctx.lineWidth = 1;
+ ctx.strokeRect( padL, padT, graphW, graphH );
+
+ // 6. Draw Inactive Channel Curves & Points (VSDC Overlay Style)
+ const otherChannels = [ 'rgb', 'red', 'green', 'blue' ].filter( c => c !== this.activeChannel );
+ otherChannels.forEach( ch => {
+
+ const pts = this.curves[ ch ];
+ const color = this._getChannelColor( ch );
+
+ ctx.strokeStyle = color;
+ ctx.globalAlpha = 0.5;
+ ctx.lineWidth = 1.2;
+
+ ctx.beginPath();
+ for ( let px = 0; px <= graphW; px += 2 ) {
+
+ const normX = px / graphW;
+ const normY = evaluateSpline( normX, pts );
+ const canvasY = padT + ( 1 - normY ) * graphH;
+
+ if ( px === 0 ) ctx.moveTo( padL + px, canvasY );
+ else ctx.lineTo( padL + px, canvasY );
+
+ }
+
+ ctx.stroke();
+
+ // Inactive channel control points
+ pts.forEach( pt => {
+
+ const cPos = this._normToCanvas( pt );
+ ctx.fillStyle = color;
+ ctx.beginPath();
+ ctx.arc( cPos.x, cPos.y, 2.5, 0, Math.PI * 2 );
+ ctx.fill();
+ ctx.strokeStyle = '#0a0a0e';
+ ctx.lineWidth = 1;
+ ctx.stroke();
+
+ } );
+
+ } );
+
+ ctx.globalAlpha = 1.0;
+
+ // 7. Draw Active Channel Curve (Thin & Crisp, No Glow)
+ const activePts = this.curves[ this.activeChannel ];
+ const activeColor = this._getChannelColor( this.activeChannel );
+
+ ctx.strokeStyle = activeColor;
+ ctx.lineWidth = 1.5;
+
+ ctx.beginPath();
+ for ( let px = 0; px <= graphW; px += 2 ) {
+
+ const normX = px / graphW;
+ const normY = evaluateSpline( normX, activePts );
+ const canvasY = padT + ( 1 - normY ) * graphH;
+
+ if ( px === 0 ) ctx.moveTo( padL + px, canvasY );
+ else ctx.lineTo( padL + px, canvasY );
+
+ }
+
+ ctx.stroke();
+
+ // 8. Draw Active Channel Control Points (Elegant Dots)
+ activePts.forEach( ( pt, idx ) => {
+
+ const cPos = this._normToCanvas( pt );
+ const isSelected = idx === this.selectedIndex;
+
+ // Draw outer halo ring if selected
+ if ( isSelected ) {
+
+ ctx.beginPath();
+ ctx.arc( cPos.x, cPos.y, 5.5, 0, Math.PI * 2 );
+ ctx.strokeStyle = '#00aaff';
+ ctx.lineWidth = 1.0;
+ ctx.stroke();
+
+ }
+
+ // Point body - uniform clean size
+ ctx.fillStyle = activeColor;
+ ctx.beginPath();
+ ctx.arc( cPos.x, cPos.y, 3.2, 0, Math.PI * 2 );
+ ctx.fill();
+
+ ctx.strokeStyle = isSelected ? '#ffffff' : '#0a0a0e';
+ ctx.lineWidth = 1.2;
+ ctx.stroke();
+
+ } );
+
+ }
+
+}
diff --git a/examples/jsm/inspector/extensions/color-grading/LUT3DStyle.js b/examples/jsm/inspector/extensions/color-grading/LUT3DStyle.js
new file mode 100644
index 00000000000000..dc23f2cc99cee3
--- /dev/null
+++ b/examples/jsm/inspector/extensions/color-grading/LUT3DStyle.js
@@ -0,0 +1,1183 @@
+/**
+ * LUT3DStyle.js - Unified CSS stylesheet module for 3D LUT Generator extension
+ */
+
+export class LUT3DStyle {
+
+ static init() {
+
+ if ( document.getElementById( 'lut3d-generator-style' ) ) return;
+
+ const style = document.createElement( 'style' );
+ style.id = 'lut3d-generator-style';
+ style.textContent = /* css */`
+@scope (.lut-container) {
+
+ :scope {
+ position: relative !important;
+ flex-grow: 1;
+ height: 100%;
+ overflow-y: auto;
+ overflow-x: hidden;
+ padding: 8px;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ box-sizing: border-box;
+
+ /* Ultra-Subtle Transparent Blueprint / Checkerboard Grid Background */
+ background-color: transparent;
+ background-image:
+ linear-gradient(45deg, rgba(255, 255, 255, 0.008) 25%, transparent 25%),
+ linear-gradient(-45deg, rgba(255, 255, 255, 0.008) 25%, transparent 25%),
+ linear-gradient(45deg, transparent 75%, rgba(255, 255, 255, 0.008) 75%),
+ linear-gradient(-45deg, transparent 75%, rgba(255, 255, 255, 0.008) 75%),
+ linear-gradient(rgba(0, 170, 255, 0.015) 1px, transparent 1px),
+ linear-gradient(90deg, rgba(0, 170, 255, 0.015) 1px, transparent 1px);
+ background-size: 24px 24px, 24px 24px, 24px 24px, 24px 24px, 12px 12px, 12px 12px;
+ background-position: 0 0, 0 12px, 12px -12px, -12px 0px, -1px -1px, -1px -1px;
+ }
+
+ .lut-toolbar {
+ display: none !important;
+ }
+
+ /* Ultra-Premium Floating Glass Control Dock inside lut-container */
+ .lut-dock-container {
+ position: absolute !important;
+ top: 12px !important;
+ left: 50% !important;
+ transform: translateX(-50%) !important;
+ display: flex !important;
+ align-items: center !important;
+ justify-content: space-between !important;
+ padding: 6px 14px !important;
+ background: rgba(22, 22, 28, 0.85) !important;
+ backdrop-filter: blur(12px) !important;
+ border: 1px solid rgba(74, 74, 90, 0.4) !important;
+ border-radius: 8px !important;
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4) !important;
+ z-index: 100 !important;
+ gap: 12px !important;
+ user-select: none !important;
+ box-sizing: border-box !important;
+ width: calc(100% - 32px) !important;
+ max-width: 960px !important;
+ }
+
+ .lut-dock-container.is-compact .lut-dock-label,
+ .lut-dock-container.is-compact .lut-dock-text {
+ display: none !important;
+ }
+
+ .lut-dock-group {
+ display: flex !important;
+ align-items: center !important;
+ gap: 6px !important;
+ flex-wrap: wrap !important;
+ }
+
+ .lut-dock-label {
+ font-family: var(--font-family, sans-serif);
+ font-size: 11px;
+ font-weight: 500;
+ color: var(--text-secondary, #9a9aab);
+ white-space: nowrap;
+ }
+
+ .lut-dock-select {
+ height: 24px;
+ padding: 0 4px;
+ font-family: var(--font-family, sans-serif);
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--text-primary, #e0e0e0);
+ background: transparent !important;
+ border: none !important;
+ outline: none;
+ cursor: pointer;
+ transition: color 0.15s;
+ }
+
+ .lut-dock-select option,
+ .lut-container select option {
+ background-color: #1e1e24;
+ color: #e0e0e0;
+ }
+
+ .lut-dock-select:hover {
+ color: var(--color-accent, #00aaff);
+ }
+
+ .lut-dock-btn {
+ height: 24px;
+ padding: 0 6px;
+ font-family: var(--font-family, sans-serif);
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--text-primary, #e0e0e0);
+ background: transparent !important;
+ border: none !important;
+ outline: none;
+ cursor: pointer;
+ transition: color 0.15s ease, opacity 0.15s ease;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 4px;
+ white-space: nowrap;
+ }
+
+ .lut-dock-btn.lut-dock-icon-btn {
+ width: 20px !important;
+ padding: 0 !important;
+ }
+
+ .lut-dock-btn:hover {
+ color: var(--color-accent, #00aaff) !important;
+ }
+
+ .lut-dock-btn:active:not(:has(.lut-context-menu)) {
+ transform: scale(0.95);
+ }
+
+ .lut-dock-btn.active {
+ color: var(--color-accent, #00aaff) !important;
+ text-shadow: 0 0 8px rgba(0, 170, 255, 0.6);
+ }
+
+ /* Glass Context Menu Popover */
+ .lut-context-menu {
+ position: absolute;
+ top: calc(100% + 4px);
+ right: 0;
+ background: rgba(22, 22, 28, 0.95);
+ border: 1px solid rgba(74, 74, 90, 0.5);
+ border-radius: 6px;
+ box-shadow: 0 6px 20px rgba(0, 0, 0, 0.5), 0 0 10px rgba(0, 170, 255, 0.2);
+ padding: 4px;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ z-index: 100;
+ min-width: 165px;
+ backdrop-filter: blur(12px);
+ animation: lutMenuFadeIn 0.15s ease-out;
+ text-align: left !important;
+ }
+
+ .lut-submenu {
+ top: -4px !important;
+ left: calc(100% + 4px) !important;
+ right: auto !important;
+ min-width: 155px !important;
+ }
+
+ @keyframes lutMenuFadeIn {
+ from { opacity: 0; transform: translateY(-4px); }
+ to { opacity: 1; transform: translateY(0); }
+ }
+
+ .lut-menu-item {
+ display: flex !important;
+ align-items: center !important;
+ justify-content: flex-start !important;
+ text-align: left !important;
+ gap: 8px;
+ padding: 6px 10px;
+ font-family: var(--font-family, sans-serif);
+ font-size: 11px;
+ font-weight: 500;
+ color: var(--text-primary, #e0e0e0);
+ border-radius: 4px;
+ cursor: pointer;
+ transition: background 0.12s, color 0.12s;
+ white-space: nowrap;
+ }
+
+ .lut-menu-item span {
+ text-align: left !important;
+ }
+
+ .lut-menu-item:hover {
+ background: rgba(0, 170, 255, 0.15);
+ color: var(--color-accent, #00aaff);
+ }
+
+ .lut-menu-item:active:not(:has(.lut-context-menu)) {
+ transform: scale(0.98);
+ }
+
+ .lut-menu-item svg {
+ color: var(--text-secondary, #9a9aab);
+ transition: color 0.12s;
+ flex-shrink: 0;
+ }
+
+ .lut-menu-item:hover svg {
+ color: var(--color-accent, #00aaff);
+ }
+
+ .lut-menu-divider {
+ height: 1px;
+ background: rgba(74, 74, 90, 0.4);
+ margin: 2px 0;
+ }
+
+ .lut-toolbar-group {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ }
+
+ .lut-toolbar-center {
+ position: absolute;
+ left: 50%;
+ top: 50%;
+ transform: translate(-50%, -50%);
+ display: flex;
+ gap: 2px;
+ background: rgba(30, 30, 36, 0.6);
+ padding: 2px;
+ border-radius: 6px;
+ border: 1px solid var(--profiler-border, rgba(74, 74, 90, 0.4));
+ }
+
+ .lut-toolbar-label {
+ font-size: 11px;
+ color: var(--text-secondary, #9a9aab);
+ }
+
+ .lut-panel {
+ display: flex;
+ flex-direction: column;
+ justify-content: stretch;
+ align-items: stretch;
+ width: 100%;
+ height: 100%;
+ min-height: 100%;
+ flex-grow: 1;
+ box-sizing: border-box;
+ position: relative;
+ }
+
+ .lut-container, .lut-cards-row {
+ cursor: grab;
+ }
+
+ .lut-container.is-panning, .lut-cards-row.is-panning {
+ cursor: grabbing !important;
+ user-select: none !important;
+ }
+
+ .lut-cards-row {
+ display: flex !important;
+ flex-direction: row !important;
+ justify-content: center !important;
+ align-items: center !important;
+ align-content: center !important;
+ gap: 12px !important;
+ flex-wrap: nowrap !important;
+ overflow-x: auto !important;
+ overflow-y: hidden !important;
+ padding: 50px 32px 10px 32px !important;
+ background: transparent !important;
+ border: none !important;
+ width: 100% !important;
+ height: 100% !important;
+ flex-grow: 1 !important;
+ box-sizing: border-box !important;
+ margin: 0 !important;
+ touch-action: pan-x pan-y !important;
+ -webkit-overflow-scrolling: touch;
+ scrollbar-width: thin;
+ scrollbar-color: var(--profiler-border, rgba(74, 74, 90, 0.6)) transparent;
+ }
+
+ .lut-cards-row > .lut-card:first-child {
+ margin-left: 24px !important;
+ }
+
+ .lut-cards-row > .lut-card:last-child {
+ margin-right: 24px !important;
+ }
+
+ .lut-cards-row::-webkit-scrollbar,
+ .lut-wheels-row::-webkit-scrollbar,
+ .lut-modules-row::-webkit-scrollbar,
+ .lut-container::-webkit-scrollbar {
+ width: 5px;
+ height: 5px;
+ }
+
+ .lut-cards-row::-webkit-scrollbar-track,
+ .lut-wheels-row::-webkit-scrollbar-track,
+ .lut-modules-row::-webkit-scrollbar-track,
+ .lut-container::-webkit-scrollbar-track {
+ background: rgba(0, 0, 0, 0.2);
+ border-radius: 3px;
+ }
+
+ .lut-cards-row::-webkit-scrollbar-thumb,
+ .lut-wheels-row::-webkit-scrollbar-thumb,
+ .lut-modules-row::-webkit-scrollbar-thumb,
+ .lut-container::-webkit-scrollbar-thumb {
+ background: var(--profiler-border, rgba(74, 74, 90, 0.6));
+ border-radius: 3px;
+ }
+
+ .lut-cards-row::-webkit-scrollbar-thumb:hover,
+ .lut-wheels-row::-webkit-scrollbar-thumb:hover,
+ .lut-modules-row::-webkit-scrollbar-thumb:hover,
+ .lut-container::-webkit-scrollbar-thumb:hover {
+ background: var(--color-accent, #00aaff);
+ }
+
+ .lut-wheels-row, .lut-modules-row {
+ display: flex !important;
+ flex-direction: row !important;
+ justify-content: flex-start !important;
+ align-items: stretch !important;
+ gap: 12px !important;
+ flex-wrap: nowrap !important;
+ overflow-x: auto !important;
+ overflow-y: hidden !important;
+ padding: 6px 4px !important;
+ background: transparent !important;
+ border: none !important;
+ width: 100% !important;
+ box-sizing: border-box !important;
+ }
+
+ /* Modular Cards - Standard Unified Card Specification */
+ .lut-card, .mini-module-card {
+ display: flex !important;
+ flex-direction: column !important;
+ justify-content: space-between !important;
+ align-items: center !important;
+ background: rgba(30, 30, 36, 0.85) !important;
+ border: 1px solid rgba(74, 74, 90, 0.4) !important;
+ border-radius: 8px !important;
+ padding: 1px 10px 8px 10px !important;
+ width: 175px !important;
+ min-width: 175px !important;
+ max-width: 175px !important;
+ flex-shrink: 0 !important;
+ flex-grow: 0 !important;
+ user-select: none !important;
+ box-sizing: border-box !important;
+ gap: 6px !important;
+ backdrop-filter: blur(8px) !important;
+ transition: opacity 0.2s, border-color 0.25s ease, box-shadow 0.25s ease !important;
+ }
+
+ /* Subtle Blue Glow when Hovered, Focused or Edited */
+ .lut-card:hover,
+ .lut-card:focus-within,
+ .lut-card.lut-card-active {
+ border-color: rgba(0, 170, 255, 0.6) !important;
+ box-shadow: 0 0 14px rgba(0, 170, 255, 0.3), 0 0 2px rgba(0, 170, 255, 0.6) !important;
+ }
+
+ .lut-card.curves-card,
+ .lut-card.preview-card {
+ width: 225px !important;
+ min-width: 225px !important;
+ max-width: 225px !important;
+ }
+
+ /* Renderer (Start Node) & Output (End Node) Cards Glowing Highlights */
+ .lut-card.renderer-card {
+ border: 1px solid rgba(0, 170, 255, 0.6) !important;
+ box-shadow: 0 0 12px rgba(0, 170, 255, 0.25), inset 0 0 8px rgba(0, 170, 255, 0.1) !important;
+ }
+
+ .lut-card.output-card {
+ border: 1px solid rgba(0, 220, 180, 0.6) !important;
+ box-shadow: 0 0 12px rgba(0, 220, 180, 0.25), inset 0 0 8px rgba(0, 220, 180, 0.1) !important;
+ }
+
+ .lut-card.renderer-card .lut-card-title {
+ color: #00aaff !important;
+ }
+
+ .lut-card.output-card .lut-card-title {
+ color: #00dcb4 !important;
+ }
+
+ .lut-output-info {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ padding: 6px 4px;
+ width: 100%;
+ box-sizing: border-box;
+ }
+
+ .lut-output-line {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ font-size: 11px;
+ line-height: 16px;
+ }
+
+ .lut-output-label {
+ color: var(--text-secondary, #9a9aab);
+ font-weight: 500;
+ }
+
+ .lut-output-val {
+ color: var(--text-primary, #e0e0e0);
+ font-weight: 600;
+ }
+
+ .lut-output-badge {
+ background: rgba(0, 220, 180, 0.2);
+ border: 1px solid rgba(0, 220, 180, 0.5);
+ color: #00dcb4;
+ padding: 1px 6px;
+ border-radius: 4px;
+ font-size: 10px;
+ }
+
+ .lut-card-header {
+ display: flex !important;
+ align-items: center !important;
+ justify-content: center !important;
+ position: relative !important;
+ width: calc(100% + 16px) !important;
+ min-height: 28px !important;
+ margin-bottom: 6px !important;
+ padding: 0 20px !important;
+ border-bottom: 1px solid rgba(74, 74, 90, 0.3) !important;
+ box-sizing: border-box !important;
+ }
+
+ .lut-card-drag-handle {
+ position: absolute !important;
+ left: 4px !important;
+ top: 0px !important;
+ bottom: 0px !important;
+ margin: 0 !important;
+ height: 100% !important;
+ display: flex !important;
+ align-items: center !important;
+ z-index: 5 !important;
+ }
+
+ .lut-card-remove-btn {
+ position: absolute !important;
+ right: 4px !important;
+ top: 0px !important;
+ bottom: 0px !important;
+ margin: 0 !important;
+ height: 100% !important;
+ display: flex !important;
+ align-items: center !important;
+ justify-content: center !important;
+ background: transparent !important;
+ border: none !important;
+ color: var(--text-secondary, #9a9aab) !important;
+ cursor: pointer !important;
+ padding: 0 !important;
+ width: 18px !important;
+ opacity: 0.6;
+ z-index: 5 !important;
+ transition: color 0.15s, opacity 0.15s !important;
+ }
+
+ .lut-card-remove-btn:hover {
+ color: var(--color-accent, #00aaff) !important;
+ opacity: 1 !important;
+ }
+
+ .lut-card-title {
+ display: flex !important;
+ align-items: center !important;
+ justify-content: center !important;
+ width: 100% !important;
+ height: 100% !important;
+ text-align: center !important;
+ font-family: var(--font-family, sans-serif) !important;
+ font-size: 11px !important;
+ font-weight: 600 !important;
+ color: var(--text-primary, #e0e0e0) !important;
+ letter-spacing: 0.3px !important;
+ margin: 0 !important;
+ padding: 0 !important;
+ white-space: nowrap !important;
+ overflow: hidden !important;
+ text-overflow: ellipsis !important;
+ }
+
+ .lut-card-reset-btn, .lut-container .card-reset-btn {
+ background: transparent !important;
+ border: none !important;
+ color: var(--text-secondary, #9a9aab) !important;
+ cursor: pointer !important;
+ padding: 0 !important;
+ margin: 0 !important;
+ width: 20px !important;
+ height: 100% !important;
+ display: flex !important;
+ align-items: center !important;
+ justify-content: center !important;
+ opacity: 0.6;
+ transition: color 0.15s, opacity 0.15s, transform 0.15s !important;
+ }
+
+ .lut-card-reset-btn:hover, .lut-container .card-reset-btn:hover {
+ color: var(--color-accent, #00aaff) !important;
+ opacity: 1 !important;
+ transform: scale(1.1);
+ }
+
+ /* Flow Connector Nodes between Cards */
+ .lut-flow-connector {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 12px;
+ min-width: 12px;
+ height: 100%;
+ position: relative;
+ flex-shrink: 0;
+ user-select: none;
+ pointer-events: none;
+ }
+
+ .lut-flow-connector::before {
+ content: '';
+ position: absolute;
+ top: 50%;
+ left: -6px;
+ right: -6px;
+ height: 2px;
+ background: repeating-linear-gradient(90deg, rgba(74, 74, 90, 0.4) 0px, rgba(74, 74, 90, 0.4) 3px, transparent 3px, transparent 6px);
+ transform: translateY(-50%);
+ }
+
+ @keyframes lutDashMarch {
+ 0% { background-position: 0 0; }
+ 100% { background-position: 12px 0; }
+ }
+
+ :scope.is-live-active .lut-flow-connector::before {
+ background: repeating-linear-gradient(90deg, #00aaff 0px, #00aaff 4px, transparent 4px, transparent 8px);
+ background-size: 12px 100%;
+ animation: lutDashMarch 0.5s linear infinite;
+ box-shadow: 0 0 8px rgba(0, 170, 255, 0.6);
+ }
+
+ /* Drag & Drop Moving Card State */
+ .lut-card {
+ cursor: default;
+ transition: transform 0.15s ease, opacity 0.15s ease;
+ }
+
+ .lut-card-header {
+ cursor: grab !important;
+ }
+
+ .lut-card-header:active {
+ cursor: grabbing !important;
+ }
+
+ .lut-card-nodrag .lut-card-header {
+ cursor: default !important;
+ }
+
+ .lut-card.lut-card-moving {
+ opacity: 0.2 !important;
+ border: 1px solid var(--color-accent, #00aaff) !important;
+ box-shadow: 0 4px 16px rgba(0, 170, 255, 0.3) !important;
+ z-index: 10;
+ }
+
+ /* Controls, Inputs & Sliders */
+ .lut-param-box {
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+ width: 100%;
+ }
+
+ .lut-param-top {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ width: 100%;
+ gap: 6px;
+ }
+
+ .lut-param-label {
+ font-family: var(--font-family, sans-serif);
+ font-size: 11px;
+ color: var(--text-secondary, #9a9aab);
+ font-weight: 500;
+ white-space: nowrap;
+ flex-shrink: 0;
+ }
+
+ .lut-select, .lut-container select, .lut-toolbar select {
+ height: 22px;
+ font-family: var(--font-family, sans-serif);
+ font-size: 11px;
+ background: transparent !important;
+ border: none !important;
+ color: var(--text-primary, #e0e0e0);
+ padding: 0 4px;
+ outline: none;
+ cursor: pointer;
+ flex-shrink: 1;
+ min-width: 40px;
+ }
+
+ .lut-select-compact {
+ height: 20px;
+ font-size: 10.5px;
+ max-width: 92px;
+ min-width: 40px;
+ padding: 0 2px;
+ flex-shrink: 1;
+ }
+
+ /* Slider Controls inside Cards */
+ .lut-card input[type="range"].inspector-slider,
+ .lut-container input[type="range"].inspector-slider {
+ -webkit-appearance: none;
+ appearance: none;
+ width: 100%;
+ height: 6px !important;
+ background: rgba(0, 0, 0, 0.4) !important;
+ border-radius: 3px !important;
+ border: 1px solid rgba(74, 74, 90, 0.5) !important;
+ outline: none !important;
+ margin: 6px 0 !important;
+ padding: 0 !important;
+ }
+
+ .lut-card input[type="range"].inspector-slider::-webkit-slider-thumb,
+ .lut-container input[type="range"].inspector-slider::-webkit-slider-thumb {
+ -webkit-appearance: none;
+ appearance: none;
+ width: 13px !important;
+ height: 13px !important;
+ background: var(--profiler-background, #1e1e24) !important;
+ border: 1.5px solid var(--color-accent, #00aaff) !important;
+ border-radius: 3px !important;
+ cursor: pointer !important;
+ margin-top: -0.5px !important;
+ }
+
+ .lut-card input[type="range"].inspector-slider::-moz-range-thumb,
+ .lut-container input[type="range"].inspector-slider::-moz-range-thumb {
+ width: 13px !important;
+ height: 13px !important;
+ background: var(--profiler-background, #1e1e24) !important;
+ border: 1.5px solid var(--color-accent, #00aaff) !important;
+ border-radius: 3px !important;
+ cursor: pointer !important;
+ }
+
+ .lut-card input[type="range"].inspector-slider::-moz-range-track,
+ .lut-container input[type="range"].inspector-slider::-moz-range-track {
+ width: 100%;
+ height: 6px !important;
+ background: rgba(0, 0, 0, 0.4) !important;
+ border-radius: 3px !important;
+ border: 1px solid rgba(74, 74, 90, 0.5) !important;
+ }
+
+ .lut-num-input {
+ width: 52px;
+ min-width: 36px;
+ flex-shrink: 1;
+ background: transparent;
+ border: none !important;
+ color: var(--text-primary, #e0e0e0);
+ font-family: var(--font-mono, monospace);
+ font-size: 11px;
+ font-weight: 600;
+ text-align: right !important;
+ outline: none;
+ padding: 0 2px;
+ transition: color 0.15s;
+ }
+
+ .lut-num-input:focus {
+ color: var(--color-accent, #00aaff);
+ }
+
+ .lut-rgb-input {
+ width: 100%;
+ background: transparent;
+ border: none;
+ color: var(--text-primary, #e0e0e0);
+ font-family: var(--font-mono, monospace);
+ font-size: 10px;
+ font-weight: 600;
+ text-align: center !important;
+ border-radius: 0 !important;
+ outline: none;
+ padding: 2px 2px 2px 0;
+ transition: border-color 0.15s;
+ }
+
+ .lut-rgb-input:focus {
+ border-bottom-color: var(--color-accent, #00aaff) !important;
+ }
+
+ .lut-rgb-input-y { border-bottom: 1px solid #aaaaaa; }
+ .lut-rgb-input-r { border-bottom: 1px solid #ff4d4d; }
+ .lut-rgb-input-g { border-bottom: 1px solid #4dff4d; }
+ .lut-rgb-input-b { border-bottom: 1px solid #4d88ff; }
+
+ .lut-wheel-canvas {
+ width: 110px;
+ height: 110px;
+ cursor: crosshair;
+ touch-action: none;
+ border-radius: 50%;
+ margin-bottom: 5px;
+ }
+
+ .lut-wheel-inputs-row {
+ display: flex;
+ gap: 3px;
+ width: 100%;
+ margin-top: 4px;
+ justify-content: space-between;
+ }
+
+ .lut-wheel-input-box {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ flex: 1;
+ min-width: 0;
+ }
+
+ /* Preview Tab UI */
+ .lut-preview-box {
+ background: rgba(30, 30, 36, 0.85);
+ padding: 10px;
+ border-radius: 8px;
+ border: 1px solid rgba(74, 74, 90, 0.4);
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ backdrop-filter: blur(8px);
+ }
+
+ .lut-preview-title {
+ font-size: 11px;
+ font-weight: 700;
+ color: #e0e0e0;
+ border-bottom: 1px solid #2a2a36;
+ padding-bottom: 4px;
+ }
+
+ .lut-preview-canvas {
+ width: 100%;
+ height: 56px;
+ min-height: 56px;
+ border-radius: 4px;
+ border: 1px solid #2a2a36;
+ background: #000;
+ image-rendering: pixelated;
+ }
+
+ .lut-btn-group {
+ display: flex;
+ gap: 8px;
+ }
+
+ /* Vertical Mode Layout */
+ .profiler-panel.position-left .lut-container,
+ .profiler-panel.position-right .lut-container,
+ .lut-container.is-vertical {
+ overflow-y: auto !important;
+ overflow-x: hidden !important;
+ }
+
+ .profiler-panel.position-left .lut-panel,
+ .profiler-panel.position-right .lut-panel,
+ .lut-panel.is-vertical {
+ height: auto !important;
+ min-height: 100% !important;
+ }
+
+ .profiler-panel.position-left .lut-cards-row,
+ .profiler-panel.position-right .lut-cards-row,
+ .lut-container.is-vertical .lut-cards-row,
+ .lut-cards-row.is-vertical {
+ flex-direction: column !important;
+ justify-content: flex-start !important;
+ align-items: center !important;
+ overflow-x: hidden !important;
+ overflow-y: auto !important;
+ padding: 56px 12px 24px 12px !important;
+ gap: 12px !important;
+ width: 100% !important;
+ height: 100% !important;
+ }
+
+ .profiler-panel.position-left .lut-cards-row > .lut-card,
+ .profiler-panel.position-right .lut-cards-row > .lut-card,
+ .lut-container.is-vertical .lut-cards-row > .lut-card,
+ .lut-cards-row.is-vertical > .lut-card {
+ flex-shrink: 0 !important;
+ }
+
+ .profiler-panel.position-left .lut-cards-row > .lut-card:first-child,
+ .profiler-panel.position-right .lut-cards-row > .lut-card:first-child,
+ .lut-container.is-vertical .lut-cards-row > .lut-card:first-child,
+ .lut-cards-row.is-vertical > .lut-card:first-child {
+ margin-left: 0 !important;
+ margin-top: 0 !important;
+ }
+
+ .profiler-panel.position-left .lut-cards-row > .lut-card:last-child,
+ .profiler-panel.position-right .lut-cards-row > .lut-card:last-child,
+ .lut-container.is-vertical .lut-cards-row > .lut-card:last-child,
+ .lut-cards-row.is-vertical > .lut-card:last-child {
+ margin-right: 0 !important;
+ margin-bottom: 24px !important;
+ }
+
+
+
+ @keyframes lutDashMarchVertical {
+ 0% { background-position: 0 0; }
+ 100% { background-position: 0 12px; }
+ }
+
+ .profiler-panel.position-left .lut-flow-connector,
+ .profiler-panel.position-right .lut-flow-connector,
+ .lut-container.is-vertical .lut-flow-connector,
+ .lut-cards-row.is-vertical .lut-flow-connector {
+ width: 100% !important;
+ min-width: 0 !important;
+ height: 16px !important;
+ min-height: 16px !important;
+ margin: 2px 0 !important;
+ }
+
+ .profiler-panel.position-left .lut-flow-connector::before,
+ .profiler-panel.position-right .lut-flow-connector::before,
+ .lut-container.is-vertical .lut-flow-connector::before,
+ .lut-cards-row.is-vertical .lut-flow-connector::before {
+ top: -6px !important;
+ bottom: -6px !important;
+ left: 50% !important;
+ right: auto !important;
+ width: 2px !important;
+ height: auto !important;
+ background-image: repeating-linear-gradient(180deg, rgba(74, 74, 90, 0.4) 0px, rgba(74, 74, 90, 0.4) 3px, transparent 3px, transparent 6px) !important;
+ transform: translateX(-50%) !important;
+ }
+
+ :scope.is-live-active.is-vertical .lut-flow-connector::before,
+ :scope.is-live-active .lut-cards-row.is-vertical .lut-flow-connector::before {
+ background-image: repeating-linear-gradient(180deg, #00aaff 0px, #00aaff 4px, transparent 4px, transparent 8px) !important;
+ background-size: 100% 12px !important;
+ animation: lutDashMarchVertical 0.5s linear infinite !important;
+ box-shadow: 0 0 8px rgba(0, 170, 255, 0.6) !important;
+ }
+
+ /* Interactive Add Card Connector [+] Button */
+ .lut-flow-connector {
+ pointer-events: auto !important;
+ width: 24px !important;
+ min-width: 24px !important;
+ z-index: 5;
+ }
+
+ .lut-connector-add-btn {
+ width: 20px;
+ height: 20px;
+ border-radius: 6px;
+ background: rgba(22, 22, 28, 0.95);
+ border: 1px solid rgba(74, 74, 90, 0.7);
+ color: var(--text-secondary, #9a9aab);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ z-index: 6;
+ transition: transform 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275), background 0.2s, border-color 0.2s, color 0.2s, box-shadow 0.2s;
+ padding: 0;
+ outline: none;
+ user-select: none;
+ }
+
+ :scope.is-live-active .lut-connector-add-btn {
+ border-color: var(--color-accent, #00aaff);
+ color: var(--color-accent, #00aaff);
+ }
+
+ .lut-connector-add-btn svg {
+ width: 12px;
+ height: 12px;
+ display: block;
+ }
+
+ .lut-connector-add-btn:hover {
+ transform: scale(1.15);
+ background: #00aaff !important;
+ border-color: #00aaff !important;
+ color: #ffffff !important;
+ box-shadow: 0 0 6px rgba(0, 170, 255, 0.4);
+ }
+
+ .lut-connector-add-btn:active {
+ transform: scale(1.05);
+ }
+
+ /* Header Card Close Button at top-right (where reset button was) */
+ .lut-card-remove-btn {
+ position: absolute !important;
+ right: 4px !important;
+ top: 0px !important;
+ bottom: 0px !important;
+ margin: auto 0 !important;
+ width: 20px !important;
+ height: 100% !important;
+ font-size: 11px !important;
+ line-height: 23px !important;
+ display: flex !important;
+ align-items: center !important;
+ justify-content: center !important;
+ background: transparent !important;
+ border: none !important;
+ color: var(--text-secondary, #9a9aab) !important;
+ cursor: pointer !important;
+ padding: 0 !important;
+ z-index: 5;
+ opacity: 0.6;
+ transition: color 0.15s, opacity 0.15s, transform 0.15s !important;
+ }
+
+ .lut-card-remove-btn:hover {
+ color: var(--color-accent, #00aaff) !important;
+ opacity: 1 !important;
+ transform: scale(1.1);
+ }
+
+ /* Add Card Modal Window Overlay */
+ .lut-modal-overlay {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(10, 10, 14, 0.75);
+ backdrop-filter: blur(8px);
+ z-index: 1000;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ animation: lutModalFadeIn 0.2s ease-out;
+ padding: 16px;
+ box-sizing: border-box;
+ }
+
+ @keyframes lutModalFadeIn {
+ from { opacity: 0; transform: scale(0.97); }
+ to { opacity: 1; transform: scale(1); }
+ }
+
+ .lut-modal-content {
+ background: rgba(22, 22, 28, 0.95);
+ border: 1px solid rgba(74, 74, 90, 0.6);
+ border-radius: 12px;
+ box-shadow: 0 12px 30px rgba(0, 0, 0, 0.5);
+ width: 480px;
+ max-width: 92%;
+ height: 70% !important;
+ max-height: 70% !important;
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ padding: 16px 20px;
+ box-sizing: border-box;
+ backdrop-filter: blur(16px);
+ user-select: none;
+ }
+
+ .lut-modal-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ border-bottom: 1px solid rgba(74, 74, 90, 0.4);
+ padding-bottom: 10px;
+ flex-shrink: 0;
+ }
+
+ .lut-modal-title {
+ font-family: var(--font-family, sans-serif);
+ font-size: 14px;
+ font-weight: 700;
+ color: var(--text-primary, #ffffff);
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ }
+
+ .lut-modal-close-btn {
+ background: transparent;
+ border: none;
+ color: var(--text-secondary, #9a9aab);
+ font-size: 16px;
+ cursor: pointer;
+ padding: 4px;
+ border-radius: 4px;
+ transition: color 0.15s, background 0.15s;
+ }
+
+ .lut-modal-close-btn:hover {
+ color: #ffffff;
+ }
+
+ .lut-modal-filter-box {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ background: rgba(30, 30, 38, 0.9);
+ border: 1px solid rgba(74, 74, 90, 0.5);
+ border-radius: 8px;
+ padding: 7px 12px;
+ transition: border-color 0.15s, box-shadow 0.15s;
+ flex-shrink: 0;
+ }
+
+ .lut-modal-filter-box:focus-within {
+ border-color: #00aaff;
+ box-shadow: 0 0 6px rgba(0, 170, 255, 0.25);
+ }
+
+ .lut-modal-filter-icon {
+ color: var(--text-secondary, #9a9aab);
+ font-size: 13px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ }
+
+ .lut-modal-filter-input {
+ background: transparent !important;
+ border: none !important;
+ outline: none !important;
+ color: var(--text-primary, #ffffff) !important;
+ font-family: var(--font-family, sans-serif) !important;
+ font-size: 12px !important;
+ width: 100% !important;
+ padding: 0 !important;
+ margin: 0 !important;
+ }
+
+ .lut-modal-filter-input::placeholder {
+ color: var(--text-secondary, #6c6c7d) !important;
+ }
+
+ .lut-modal-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
+ gap: 10px;
+ overflow-y: auto;
+ padding-right: 4px;
+ flex: 1 !important;
+ min-height: 0 !important;
+ align-content: start;
+ scrollbar-width: thin;
+ scrollbar-color: var(--profiler-border, rgba(74, 74, 90, 0.6)) transparent;
+ }
+
+ .lut-modal-grid::-webkit-scrollbar {
+ width: 4px;
+ height: 4px;
+ }
+
+ .lut-modal-grid::-webkit-scrollbar-track {
+ background: rgba(0, 0, 0, 0.2);
+ border-radius: 2px;
+ }
+
+ .lut-modal-grid::-webkit-scrollbar-thumb {
+ background: var(--profiler-border, rgba(74, 74, 90, 0.6));
+ border-radius: 2px;
+ }
+
+ .lut-modal-grid::-webkit-scrollbar-thumb:hover {
+ background: var(--color-accent, #00aaff);
+ }
+
+ .lut-modal-empty-msg {
+ text-align: center;
+ font-family: var(--font-family, sans-serif);
+ font-size: 12px;
+ color: var(--text-secondary, #9a9aab);
+ padding: 24px 12px;
+ grid-column: 1 / -1;
+ }
+
+ .lut-modal-option {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ text-align: center;
+ gap: 8px;
+ background: rgba(30, 30, 38, 0.85);
+ border: 1px solid transparent;
+ border-radius: 10px;
+ padding: 14px 8px;
+ cursor: pointer;
+ box-sizing: border-box;
+ transition: background 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
+ }
+
+ .lut-modal-option:hover,
+ .lut-modal-option.is-focused {
+ border-color: var(--color-accent, #00aaff);
+ box-shadow: 0 0 10px rgba(0, 170, 255, 0.2);
+ }
+
+ .lut-modal-option-icon {
+ width: 28px;
+ height: 28px;
+ background: transparent !important;
+ color: #d0d0dc;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ transition: color 0.15s;
+ }
+
+ .lut-modal-option:hover .lut-modal-option-icon,
+ .lut-modal-option.is-focused .lut-modal-option-icon {
+ color: var(--color-accent, #00aaff);
+ }
+
+ .lut-modal-option-title {
+ font-family: var(--font-family, sans-serif);
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--text-primary, #e0e0e0);
+ text-align: center;
+ line-height: 1.3;
+ word-break: break-word;
+ }
+
+}
+`;
+
+ document.head.appendChild( style );
+
+ }
+
+}
diff --git a/examples/jsm/inspector/extensions/color-grading/LUTMath.js b/examples/jsm/inspector/extensions/color-grading/LUTMath.js
new file mode 100644
index 00000000000000..0a2f8deb214ad5
--- /dev/null
+++ b/examples/jsm/inspector/extensions/color-grading/LUTMath.js
@@ -0,0 +1,788 @@
+/**
+ * LUTMath.js - Mathematical operations for LUT 3D Generator
+ */
+
+import { REVISION } from 'three';
+
+// Monotone cubic spline evaluation for curve points
+export function evaluateSpline( t, points ) {
+
+ if ( ! points || points.length === 0 ) return t;
+ if ( points.length === 1 ) return points[ 0 ].y;
+
+ const pts = points;
+
+ if ( t <= pts[ 0 ].x ) return pts[ 0 ].y;
+ if ( t >= pts[ pts.length - 1 ].x ) return pts[ pts.length - 1 ].y;
+
+ // Find segment
+ let i = 0;
+ while ( i < pts.length - 1 && pts[ i + 1 ].x < t ) {
+
+ i ++;
+
+ }
+
+ const p0 = pts[ i ];
+ const p1 = pts[ i + 1 ];
+ const h = p1.x - p0.x;
+
+ if ( h <= 0.00001 ) return p0.y;
+
+ // Secants
+ const d = ( p1.y - p0.y ) / h;
+
+ // Tangents computation (Fritsch-Carlson monotone cubic Hermite)
+ let m0 = d;
+ let m1 = d;
+
+ if ( i > 0 ) {
+
+ const prevD = ( p0.y - pts[ i - 1 ].y ) / ( p0.x - pts[ i - 1 ].x );
+ m0 = 0.5 * ( prevD + d );
+
+ }
+
+ if ( i < pts.length - 2 ) {
+
+ const nextD = ( pts[ i + 2 ].y - p1.y ) / ( pts[ i + 2 ].x - p1.x );
+ m1 = 0.5 * ( d + nextD );
+
+ }
+
+ // Hermite basis
+ const localT = ( t - p0.x ) / h;
+ const t2 = localT * localT;
+ const t3 = t2 * localT;
+
+ const h00 = 2 * t3 - 3 * t2 + 1;
+ const h10 = t3 - 2 * t2 + localT;
+ const h01 = - 2 * t3 + 3 * t2;
+ const h11 = t3 - t2;
+
+ const val = h00 * p0.y + h10 * h * m0 + h01 * p1.y + h11 * h * m1;
+
+ return Math.max( 0, Math.min( 1, val ) );
+
+}
+
+// RGB to HSV conversion
+export function rgbToHsv( r, g, b ) {
+
+ const max = Math.max( r, g, b );
+ const min = Math.min( r, g, b );
+ const d = max - min;
+ let h = 0;
+ const s = max === 0 ? 0 : d / max;
+ const v = max;
+
+ if ( max !== min ) {
+
+ switch ( max ) {
+
+ case r: h = ( g - b ) / d + ( g < b ? 6 : 0 ); break;
+ case g: h = ( b - r ) / d + 2; break;
+ case b: h = ( r - g ) / d + 4; break;
+
+ }
+
+ h /= 6;
+
+ }
+
+ return [ h, s, v ];
+
+}
+
+// HSV to RGB conversion
+export function hsvToRgb( h, s, v ) {
+
+ let r = 0, g = 0, b = 0;
+
+ const i = Math.floor( h * 6 );
+ const f = h * 6 - i;
+ const p = v * ( 1 - s );
+ const q = v * ( 1 - f * s );
+ const t = v * ( 1 - ( 1 - f ) * s );
+
+ switch ( i % 6 ) {
+
+ case 0: r = v; g = t; b = p; break;
+ case 1: r = q; g = v; b = p; break;
+ case 2: r = p; g = v; b = t; break;
+ case 3: r = p; g = q; b = v; break;
+ case 4: r = t; g = p; b = v; break;
+ case 5: r = v; g = p; b = q; break;
+
+ }
+
+ return [ r, g, b ];
+
+}
+
+// Main color transformation pipeline
+// Trilinear 3D LUT sampling for imported .CUBE datasets
+export function sample3DLUT( r, g, b, size, dataLines ) {
+
+ const nr = Math.max( 0, Math.min( 1, r ) );
+ const ng = Math.max( 0, Math.min( 1, g ) );
+ const nb = Math.max( 0, Math.min( 1, b ) );
+
+ const rx = nr * ( size - 1 );
+ const gy = ng * ( size - 1 );
+ const bz = nb * ( size - 1 );
+
+ const x0 = Math.floor( rx );
+ const x1 = Math.min( size - 1, x0 + 1 );
+ const y0 = Math.floor( gy );
+ const y1 = Math.min( size - 1, y0 + 1 );
+ const z0 = Math.floor( bz );
+ const z1 = Math.min( size - 1, z0 + 1 );
+
+ const fx = rx - x0;
+ const fy = gy - y0;
+ const fz = bz - z0;
+
+ const getVal = ( x, y, z ) => {
+
+ const index = z * size * size + y * size + x;
+ return dataLines[ index ] || [ x / ( size - 1 ), y / ( size - 1 ), z / ( size - 1 ) ];
+
+ };
+
+ const c000 = getVal( x0, y0, z0 );
+ const c100 = getVal( x1, y0, z0 );
+ const c010 = getVal( x0, y1, z0 );
+ const c110 = getVal( x1, y0, z0 );
+ const c001 = getVal( x0, y0, z1 );
+ const c101 = getVal( x1, y0, z1 );
+ const c011 = getVal( x0, y1, z1 );
+ const c111 = getVal( x1, y1, z1 );
+
+ const trilinear = ( ch ) => {
+
+ const c00 = c000[ ch ] * ( 1 - fx ) + c100[ ch ] * fx;
+ const c01 = c001[ ch ] * ( 1 - fx ) + c101[ ch ] * fx;
+ const c10 = c010[ ch ] * ( 1 - fx ) + c110[ ch ] * fx;
+ const c11 = c011[ ch ] * ( 1 - fx ) + c111[ ch ] * fx;
+
+ const c0 = c00 * ( 1 - fy ) + c10 * fy;
+ const c1 = c01 * ( 1 - fy ) + c11 * fy;
+
+ return c0 * ( 1 - fz ) + c1 * fz;
+
+ };
+
+ return [ trilinear( 0 ), trilinear( 1 ), trilinear( 2 ) ];
+
+}
+
+export function applyColorTransform( r, g, b, params, pipelineOrder = null ) {
+
+ let cr = r;
+ let cg = g;
+ let cb = b;
+
+ const order = pipelineOrder || [
+ 'whiteBalance',
+ 'exposureHue',
+ 'lift',
+ 'gammaBal',
+ 'gain',
+ 'offset',
+ 'contrast',
+ 'satVibrance'
+ ];
+
+ for ( let i = 0; i < order.length; i ++ ) {
+
+ const step = order[ i ];
+
+ if ( step.startsWith( 'importedCube_' ) && params.importedCubes && params.importedCubes[ step ] ) {
+
+ const cubeInfo = params.importedCubes[ step ];
+ const cubeData = cubeInfo.dataLines || cubeInfo.data;
+ if ( cubeData && cubeData.length > 0 ) {
+
+ const [ lr, lg, lb ] = sample3DLUT( cr, cg, cb, cubeInfo.size, cubeData );
+ const weight = cubeInfo.weight !== undefined ? cubeInfo.weight : 1.0;
+ cr = cr * ( 1 - weight ) + lr * weight;
+ cg = cg * ( 1 - weight ) + lg * weight;
+ cb = cb * ( 1 - weight ) + lb * weight;
+
+ }
+
+ continue;
+
+ }
+
+ switch ( step ) {
+
+ case 'whiteBalance':
+ if ( params.temperature !== 0 || params.tint !== 0 ) {
+
+ const temp = params.temperature / 100;
+ const tint = params.tint / 100;
+ cr += temp * 0.1;
+ cb -= temp * 0.1;
+ cg -= tint * 0.1;
+
+ }
+
+ break;
+
+ case 'exposureHue':
+ if ( params.exposure !== 0 ) {
+
+ const factor = Math.pow( 2, params.exposure );
+ cr *= factor;
+ cg *= factor;
+ cb *= factor;
+
+ }
+
+ if ( params.brightness ) {
+
+ cr += params.brightness;
+ cg += params.brightness;
+ cb += params.brightness;
+
+ }
+
+ if ( params.hueShift ) {
+
+ const [ origH, s, v ] = rgbToHsv( Math.max( 0, cr ), Math.max( 0, cg ), Math.max( 0, cb ) );
+ let h = ( origH + params.hueShift / 360 ) % 1;
+ if ( h < 0 ) h += 1;
+ const [ nr, ng, nb ] = hsvToRgb( h, s, v );
+ cr = nr; cg = ng; cb = nb;
+
+ }
+
+ break;
+
+ case 'lift':
+ if ( params.lift ) {
+
+ const ly = params.lift.y || 0;
+ const lr = params.lift.r + ly;
+ const lg = params.lift.g + ly;
+ const lb = params.lift.b + ly;
+
+ cr += lr * ( 1 - Math.max( 0, Math.min( 1, cr ) ) );
+ cg += lg * ( 1 - Math.max( 0, Math.min( 1, cg ) ) );
+ cb += lb * ( 1 - Math.max( 0, Math.min( 1, cb ) ) );
+
+ }
+
+ break;
+
+ case 'gammaBal':
+ if ( params.gammaBal ) {
+
+ const gy = params.gammaBal.y || 0;
+ const gr = Math.pow( 2.0, - ( params.gammaBal.r + gy ) );
+ const gg = Math.pow( 2.0, - ( params.gammaBal.g + gy ) );
+ const gb = Math.pow( 2.0, - ( params.gammaBal.b + gy ) );
+
+ cr = cr > 0 ? Math.pow( cr, gr ) : cr;
+ cg = cg > 0 ? Math.pow( cg, gg ) : cg;
+ cb = cb > 0 ? Math.pow( cb, gb ) : cb;
+
+ }
+
+ break;
+
+ case 'gain':
+ if ( params.gain ) {
+
+ const gy = params.gain.y || 0;
+ const sr = Math.max( 0, 1.0 + ( params.gain.r + gy ) );
+ const sg = Math.max( 0, 1.0 + ( params.gain.g + gy ) );
+ const sb = Math.max( 0, 1.0 + ( params.gain.b + gy ) );
+
+ cr *= sr;
+ cg *= sg;
+ cb *= sb;
+
+ }
+
+ break;
+
+ case 'offset':
+ if ( params.offset ) {
+
+ const oy = params.offset.y || 0;
+ cr += params.offset.r + oy;
+ cg += params.offset.g + oy;
+ cb += params.offset.b + oy;
+
+ }
+
+ break;
+
+ case 'contrast':
+ if ( params.contrast !== 1 ) {
+
+ const pivot = params.contrastPivot !== undefined ? params.contrastPivot : 0.5;
+ const c = params.contrast;
+ cr = pivot + ( cr - pivot ) * c;
+ cg = pivot + ( cg - pivot ) * c;
+ cb = pivot + ( cb - pivot ) * c;
+
+ }
+
+ break;
+
+ case 'satVibrance':
+ if ( params.saturation !== 1 || params.vibrance !== 0 ) {
+
+ const [ h, origS, origV ] = rgbToHsv( Math.max( 0, cr ), Math.max( 0, cg ), Math.max( 0, cb ) );
+ let s = origS;
+
+ if ( params.saturation !== 1 ) {
+
+ s *= params.saturation;
+
+ }
+
+ if ( params.vibrance !== 0 ) {
+
+ const vib = params.vibrance;
+ if ( vib > 0 ) {
+
+ s += ( 1 - s ) * vib * 0.5;
+
+ } else {
+
+ s += s * vib * 0.5;
+
+ }
+
+ }
+
+ s = Math.max( 0, Math.min( 1, s ) );
+ const v = Math.max( 0, Math.min( 1, origV ) );
+ const [ nr, ng, nb ] = hsvToRgb( h, s, v );
+ cr = nr; cg = ng; cb = nb;
+
+ }
+
+ break;
+
+ }
+
+ }
+
+ // Gamma Evaluation
+ if ( params.gamma !== 1 && params.gamma > 0 ) {
+
+ const invGamma = 1 / params.gamma;
+ cr = Math.pow( Math.max( 0, cr ), invGamma );
+ cg = Math.pow( Math.max( 0, cg ), invGamma );
+ cb = Math.pow( Math.max( 0, cb ), invGamma );
+
+ }
+
+ // Clamp to non-negative before Channel Mixer operations
+ cr = Math.max( 0, cr );
+ cg = Math.max( 0, cg );
+ cb = Math.max( 0, cb );
+
+ // Channel Mixer
+ if ( params.channelMixer ) {
+
+ const cm = params.channelMixer;
+ const nr = cr * cm.red.r + cg * cm.red.g + cb * cm.red.b;
+ const ng = cr * cm.green.r + cg * cm.green.g + cb * cm.green.b;
+ const nb = cr * cm.blue.r + cg * cm.blue.g + cb * cm.blue.b;
+
+ cr = nr;
+ cg = ng;
+ cb = nb;
+
+ }
+
+ // Clamp before curves evaluation (0..1)
+ cr = Math.max( 0, Math.min( 1, cr ) );
+ cg = Math.max( 0, Math.min( 1, cg ) );
+ cb = Math.max( 0, Math.min( 1, cb ) );
+
+ // Curves Evaluation
+ if ( params.curves ) {
+
+ // RGB / Master curve
+ if ( params.curves.rgb && params.curves.rgb.length > 0 ) {
+
+ cr = evaluateSpline( cr, params.curves.rgb );
+ cg = evaluateSpline( cg, params.curves.rgb );
+ cb = evaluateSpline( cb, params.curves.rgb );
+
+ }
+
+ // Individual Red, Green, Blue curves
+ if ( params.curves.red && params.curves.red.length > 0 ) {
+
+ cr = evaluateSpline( cr, params.curves.red );
+
+ }
+
+ if ( params.curves.green && params.curves.green.length > 0 ) {
+
+ cg = evaluateSpline( cg, params.curves.green );
+
+ }
+
+ if ( params.curves.blue && params.curves.blue.length > 0 ) {
+
+ cb = evaluateSpline( cb, params.curves.blue );
+
+ }
+
+ }
+
+ // Final clamp (0..1)
+ return [
+ Math.max( 0, Math.min( 1, cr ) ),
+ Math.max( 0, Math.min( 1, cg ) ),
+ Math.max( 0, Math.min( 1, cb ) )
+ ];
+
+}
+
+// Generate 3D LUT Float32Array
+export function generate3DLUTData( params, size = 32, buffer = null, pipelineOrder = null ) {
+
+ if ( buffer === null ) {
+
+ buffer = new Float32Array( size * size * size * 4 );
+
+ }
+
+ let idx = 0;
+
+ for ( let b = 0; b < size; b ++ ) {
+
+ for ( let g = 0; g < size; g ++ ) {
+
+ for ( let r = 0; r < size; r ++ ) {
+
+ const nr = r / ( size - 1 );
+ const ng = g / ( size - 1 );
+ const nb = b / ( size - 1 );
+
+ const [ tr, tg, tb ] = applyColorTransform( nr, ng, nb, params, pipelineOrder );
+
+ buffer[ idx ++ ] = tr;
+ buffer[ idx ++ ] = tg;
+ buffer[ idx ++ ] = tb;
+ buffer[ idx ++ ] = 1.0; // Alpha
+
+ }
+
+ }
+
+ }
+
+ return buffer;
+
+}
+
+// Export Adobe .CUBE file format
+export function exportCubeFormat( paramsOrBuffer, size = 32, title = 'LUT 3D Generator', pipelineOrder = null ) {
+
+ let output = `#Created by: Three.js ${REVISION} - Color Grading\n`;
+ output += `TITLE "${title}"\n`;
+ output += `LUT_3D_SIZE ${size}\n\n`;
+
+ if ( paramsOrBuffer instanceof Float32Array ) {
+
+ const buffer = paramsOrBuffer;
+ for ( let i = 0; i < buffer.length; i += 4 ) {
+
+ output += `${buffer[ i ].toFixed( 6 )} ${buffer[ i + 1 ].toFixed( 6 )} ${buffer[ i + 2 ].toFixed( 6 )}\n`;
+
+ }
+
+ } else {
+
+ const params = paramsOrBuffer;
+
+ for ( let b = 0; b < size; b ++ ) {
+
+ for ( let g = 0; g < size; g ++ ) {
+
+ for ( let r = 0; r < size; r ++ ) {
+
+ const nr = r / ( size - 1 );
+ const ng = g / ( size - 1 );
+ const nb = b / ( size - 1 );
+
+ const [ tr, tg, tb ] = applyColorTransform( nr, ng, nb, params, pipelineOrder );
+
+ output += `${tr.toFixed( 6 )} ${tg.toFixed( 6 )} ${tb.toFixed( 6 )}\n`;
+
+ }
+
+ }
+
+ }
+
+ }
+
+ return output;
+
+}
+
+// Parse .CUBE file text into data grid
+export function parseCubeFormat( text ) {
+
+ const lines = text.split( /\r?\n/ );
+ let size = 0;
+ let title = 'Imported LUT';
+ const dataLines = [];
+
+ for ( const line of lines ) {
+
+ const trimmed = line.trim();
+ if ( ! trimmed || trimmed.startsWith( '#' ) ) continue;
+
+ const upper = trimmed.toUpperCase();
+ if ( upper.startsWith( 'TITLE' ) ) {
+
+ const match = trimmed.match( /TITLE\s+"?([^"]+)"?/i );
+ if ( match ) title = match[ 1 ];
+
+ } else if ( upper.startsWith( 'LUT_3D_SIZE' ) ) {
+
+ const parts = trimmed.split( /\s+/ );
+ size = parseInt( parts[ 1 ], 10 );
+
+ } else if ( upper.startsWith( 'LUT_1D_SIZE' ) || upper.startsWith( 'DOMAIN_MIN' ) || upper.startsWith( 'DOMAIN_MAX' ) ) {
+
+ continue;
+
+ } else {
+
+ const parts = trimmed.split( /\s+/ ).map( Number );
+ if ( parts.length >= 3 && ! isNaN( parts[ 0 ] ) && ! isNaN( parts[ 1 ] ) && ! isNaN( parts[ 2 ] ) ) {
+
+ dataLines.push( [ parts[ 0 ], parts[ 1 ], parts[ 2 ] ] );
+
+ }
+
+ }
+
+ }
+
+ if ( size === 0 && dataLines.length > 0 ) {
+
+ size = Math.round( Math.cbrt( dataLines.length ) );
+
+ }
+
+ return {
+ title,
+ size,
+ data: dataLines,
+ dataLines: dataLines
+ };
+
+}
+
+// Export 2D LUT Canvas for LUTImageLoader (PNG export)
+export function exportLUTCanvas( paramsOrBuffer, size = 32, pipelineOrder = null ) {
+
+ const canvas = document.createElement( 'canvas' );
+ const width = size * size;
+ const height = size;
+ canvas.width = width;
+ canvas.height = height;
+
+ const ctx = canvas.getContext( '2d' );
+ const imgData = ctx.createImageData( width, height );
+ const data = imgData.data;
+
+ if ( paramsOrBuffer instanceof Float32Array ) {
+
+ const buffer = paramsOrBuffer;
+ let idx = 0;
+
+ for ( let b = 0; b < size; b ++ ) {
+
+ for ( let g = 0; g < size; g ++ ) {
+
+ for ( let r = 0; r < size; r ++ ) {
+
+ const tr = buffer[ idx ++ ];
+ const tg = buffer[ idx ++ ];
+ const tb = buffer[ idx ++ ];
+ idx ++; // skip alpha
+
+ const x = b * size + r;
+ const y = g;
+ const pxIdx = ( y * width + x ) * 4;
+
+ data[ pxIdx ] = Math.round( Math.max( 0, Math.min( 1, tr ) ) * 255 );
+ data[ pxIdx + 1 ] = Math.round( Math.max( 0, Math.min( 1, tg ) ) * 255 );
+ data[ pxIdx + 2 ] = Math.round( Math.max( 0, Math.min( 1, tb ) ) * 255 );
+ data[ pxIdx + 3 ] = 255;
+
+ }
+
+ }
+
+ }
+
+ } else {
+
+ const params = paramsOrBuffer;
+
+ for ( let b = 0; b < size; b ++ ) {
+
+ for ( let g = 0; g < size; g ++ ) {
+
+ for ( let r = 0; r < size; r ++ ) {
+
+ const nr = r / ( size - 1 );
+ const ng = g / ( size - 1 );
+ const nb = b / ( size - 1 );
+
+ const [ tr, tg, tb ] = applyColorTransform( nr, ng, nb, params, pipelineOrder );
+
+ const x = b * size + r;
+ const y = g;
+ const idx = ( y * width + x ) * 4;
+
+ data[ idx ] = Math.round( tr * 255 );
+ data[ idx + 1 ] = Math.round( tg * 255 );
+ data[ idx + 2 ] = Math.round( tb * 255 );
+ data[ idx + 3 ] = 255;
+
+ }
+
+ }
+
+ }
+
+ }
+
+ ctx.putImageData( imgData, 0, 0 );
+ return canvas;
+
+}
+
+
+
+// Default Parameter Object factory
+export function createDefaultParams() {
+
+ return {
+ exposure: 0,
+ brightness: 0,
+ contrast: 1.0,
+ contrastPivot: 0.5,
+ gamma: 1.0,
+ saturation: 1.0,
+ vibrance: 0,
+ temperature: 0,
+ tint: 0,
+ hueShift: 0,
+ lift: { r: 0, g: 0, b: 0, y: 0 },
+ gammaBal: { r: 0, g: 0, b: 0, y: 0 },
+ gain: { r: 0, g: 0, b: 0, y: 0 },
+ offset: { r: 0, g: 0, b: 0, y: 0 },
+ channelMixer: {
+ red: { r: 1, g: 0, b: 0 },
+ green: { r: 0, g: 1, b: 0 },
+ blue: { r: 0, g: 0, b: 1 }
+ },
+ curves: {
+ rgb: [ { x: 0, y: 0 }, { x: 1, y: 1 } ],
+ red: [ { x: 0, y: 0 }, { x: 1, y: 1 } ],
+ green: [ { x: 0, y: 0 }, { x: 1, y: 1 } ],
+ blue: [ { x: 0, y: 0 }, { x: 1, y: 1 } ]
+ }
+ };
+
+}
+
+// Presets Collection
+export const LUT_PRESETS = {
+
+ 'Neutral (Default)': createDefaultParams(),
+
+ 'Cinematic Teal & Orange': {
+ ...createDefaultParams(),
+ contrast: 1.15,
+ temperature: 15,
+ lift: { r: - 0.05, g: 0.02, b: 0.08 },
+ gain: { r: 0.08, g: 0.03, b: - 0.05 },
+ curves: {
+ rgb: [ { x: 0, y: 0 }, { x: 0.25, y: 0.2 }, { x: 0.75, y: 0.82 }, { x: 1, y: 1 } ],
+ red: [ { x: 0, y: 0 }, { x: 0.5, y: 0.48 }, { x: 1, y: 1 } ],
+ green: [ { x: 0, y: 0 }, { x: 1, y: 1 } ],
+ blue: [ { x: 0, y: 0 }, { x: 0.5, y: 0.54 }, { x: 1, y: 1 } ]
+ }
+ },
+
+ 'Vintage Warm Film': {
+ ...createDefaultParams(),
+ exposure: 0.1,
+ contrast: 0.92,
+ saturation: 0.85,
+ temperature: 25,
+ tint: 5,
+ lift: { r: 0.04, g: 0.02, b: - 0.02 },
+ gain: { r: 0.06, g: 0.04, b: - 0.04 },
+ curves: {
+ rgb: [ { x: 0, y: 0.05 }, { x: 0.5, y: 0.5 }, { x: 1, y: 0.95 } ],
+ red: [ { x: 0, y: 0 }, { x: 1, y: 1 } ],
+ green: [ { x: 0, y: 0 }, { x: 1, y: 1 } ],
+ blue: [ { x: 0, y: 0.06 }, { x: 1, y: 0.9 } ]
+ }
+ },
+
+ 'Cold Sci-Fi Blue': {
+ ...createDefaultParams(),
+ contrast: 1.2,
+ temperature: - 35,
+ tint: - 10,
+ lift: { r: - 0.04, g: - 0.01, b: 0.06 },
+ gain: { r: - 0.06, g: 0.02, b: 0.1 },
+ curves: {
+ rgb: [ { x: 0, y: 0 }, { x: 0.3, y: 0.22 }, { x: 0.7, y: 0.78 }, { x: 1, y: 1 } ],
+ red: [ { x: 0, y: 0 }, { x: 1, y: 1 } ],
+ green: [ { x: 0, y: 0 }, { x: 1, y: 1 } ],
+ blue: [ { x: 0, y: 0 }, { x: 1, y: 1 } ]
+ }
+ },
+
+ 'Cyberpunk Neon': {
+ ...createDefaultParams(),
+ contrast: 1.25,
+ saturation: 1.3,
+ vibrance: 0.4,
+ lift: { r: 0.06, g: - 0.04, b: 0.12 },
+ gain: { r: 0.1, g: - 0.02, b: 0.08 },
+ curves: {
+ rgb: [ { x: 0, y: 0 }, { x: 0.2, y: 0.15 }, { x: 0.8, y: 0.88 }, { x: 1, y: 1 } ],
+ red: [ { x: 0, y: 0 }, { x: 0.5, y: 0.55 }, { x: 1, y: 1 } ],
+ green: [ { x: 0, y: 0 }, { x: 1, y: 1 } ],
+ blue: [ { x: 0, y: 0.05 }, { x: 0.5, y: 0.6 }, { x: 1, y: 1 } ]
+ }
+ },
+
+ 'High Contrast B&W': {
+ ...createDefaultParams(),
+ contrast: 1.35,
+ saturation: 0.0,
+ curves: {
+ rgb: [ { x: 0, y: 0 }, { x: 0.25, y: 0.15 }, { x: 0.75, y: 0.88 }, { x: 1, y: 1 } ],
+ red: [ { x: 0, y: 0 }, { x: 1, y: 1 } ],
+ green: [ { x: 0, y: 0 }, { x: 1, y: 1 } ],
+ blue: [ { x: 0, y: 0 }, { x: 1, y: 1 } ]
+ }
+ }
+
+};
diff --git a/examples/jsm/inspector/extensions/color-grading/modules/BrightnessModule.js b/examples/jsm/inspector/extensions/color-grading/modules/BrightnessModule.js
new file mode 100644
index 00000000000000..b0ac1252696566
--- /dev/null
+++ b/examples/jsm/inspector/extensions/color-grading/modules/BrightnessModule.js
@@ -0,0 +1,72 @@
+import { Module } from './Module.js';
+
+const _tempRgb = [ 0, 0, 0 ];
+
+export class BrightnessModule extends Module {
+
+ constructor( params = {}, onChange = null, onRemove = null, id = 'brightness' ) {
+
+ super( id, 'Brightness', {
+ brightness: params.brightness ?? 0
+ } );
+
+ this.onChange = onChange;
+
+ const card = document.createElement( 'div' );
+ card.className = 'lut-card';
+
+ card.appendChild( this.createCardHeader( this.name, () => this.reset(), onRemove ) );
+
+ this.brightnessControl = this.createSliderControl( {
+ key: 'brightness',
+ label: 'Brightness',
+ min: - 1,
+ max: 1,
+ step: 0.02,
+ def: 0
+ } );
+
+ card.appendChild( this.brightnessControl );
+
+ this.domElement = card;
+
+ }
+
+ applyPixel( r, g, b, target = _tempRgb ) {
+
+ let cr = r;
+ let cg = g;
+ let cb = b;
+
+ const { brightness } = this.params;
+
+ if ( brightness !== 0 ) {
+
+ cr += brightness;
+ cg += brightness;
+ cb += brightness;
+
+ }
+
+ target[ 0 ] = cr;
+ target[ 1 ] = cg;
+ target[ 2 ] = cb;
+ return target;
+
+ }
+
+ reset() {
+
+ this.params.brightness = 0;
+ this.updateUI();
+ this.onParamChange();
+
+ }
+
+ updateUI() {
+
+ this.brightnessControl._setValue( this.params.brightness );
+
+ }
+
+}
diff --git a/examples/jsm/inspector/extensions/color-grading/modules/ColorWheelModule.js b/examples/jsm/inspector/extensions/color-grading/modules/ColorWheelModule.js
new file mode 100644
index 00000000000000..3883e44e344b6f
--- /dev/null
+++ b/examples/jsm/inspector/extensions/color-grading/modules/ColorWheelModule.js
@@ -0,0 +1,112 @@
+import { Module } from './Module.js';
+import { ColorWheel } from '../ColorWheel.js';
+
+const _tempRgb = [ 0, 0, 0 ];
+
+export class ColorWheelModule extends Module {
+
+ constructor( mode, title, initialParams = {}, onChange = null, onRemove = null, id = null ) {
+
+ const defaultWheelParams = { r: 0, g: 0, b: 0, y: 0 };
+ const mergedParams = Object.assign( {}, defaultWheelParams, initialParams );
+
+ super( id || mode, title, mergedParams );
+
+ this.mode = mode; // 'lift', 'gammaBal', 'gain', 'offset'
+ this.onChange = onChange;
+ this.onRemove = onRemove;
+
+ this.wheel = new ColorWheel( this.name, this.params, ( updatedVal ) => {
+
+ Object.assign( this.params, updatedVal );
+ this.onParamChange();
+
+ }, onRemove );
+
+ this.domElement = this.wheel.domElement;
+
+ }
+
+ applyPixel( r, g, b, target = _tempRgb ) {
+
+ const { r: pr, g: pg, b: pb, y: py } = this.params;
+
+ switch ( this.mode ) {
+
+ case 'lift': {
+
+ const lr = pr + py;
+ const lg = pg + py;
+ const lb = pb + py;
+
+ target[ 0 ] = r + lr * ( 1 - Math.max( 0, Math.min( 1, r ) ) );
+ target[ 1 ] = g + lg * ( 1 - Math.max( 0, Math.min( 1, g ) ) );
+ target[ 2 ] = b + lb * ( 1 - Math.max( 0, Math.min( 1, b ) ) );
+ return target;
+
+ }
+
+ case 'gammaBal': {
+
+ const gr = Math.pow( 2.0, - ( pr + py ) );
+ const gg = Math.pow( 2.0, - ( pg + py ) );
+ const gb = Math.pow( 2.0, - ( pb + py ) );
+
+ target[ 0 ] = r > 0 ? Math.pow( r, gr ) : r;
+ target[ 1 ] = g > 0 ? Math.pow( g, gg ) : g;
+ target[ 2 ] = b > 0 ? Math.pow( b, gb ) : b;
+ return target;
+
+ }
+
+ case 'gain': {
+
+ const sr = Math.max( 0, 1.0 + pr + py );
+ const sg = Math.max( 0, 1.0 + pg + py );
+ const sb = Math.max( 0, 1.0 + pb + py );
+
+ target[ 0 ] = r * sr;
+ target[ 1 ] = g * sg;
+ target[ 2 ] = b * sb;
+ return target;
+
+ }
+
+ case 'offset': {
+
+ target[ 0 ] = r + pr + py;
+ target[ 1 ] = g + pg + py;
+ target[ 2 ] = b + pb + py;
+ return target;
+
+ }
+
+ default:
+ target[ 0 ] = r;
+ target[ 1 ] = g;
+ target[ 2 ] = b;
+ return target;
+
+ }
+
+ }
+
+ reset() {
+
+ this.params.r = 0;
+ this.params.g = 0;
+ this.params.b = 0;
+ this.params.y = 0;
+
+ this.wheel.setValues( 0, 0, 0, 0 );
+ this.onParamChange();
+
+ }
+
+ updateUI() {
+
+ this.wheel.setValues( this.params.r, this.params.g, this.params.b, this.params.y );
+
+ }
+
+}
diff --git a/examples/jsm/inspector/extensions/color-grading/modules/ContrastModule.js b/examples/jsm/inspector/extensions/color-grading/modules/ContrastModule.js
new file mode 100644
index 00000000000000..798ddfdf65bdf2
--- /dev/null
+++ b/examples/jsm/inspector/extensions/color-grading/modules/ContrastModule.js
@@ -0,0 +1,82 @@
+import { Module } from './Module.js';
+
+const _tempRgb = [ 0, 0, 0 ];
+
+export class ContrastModule extends Module {
+
+ constructor( params = {}, onChange = null, onRemove = null, id = 'contrast' ) {
+
+ super( id, 'Contrast & Pivot', {
+ contrast: params.contrast ?? 1.0,
+ contrastPivot: params.contrastPivot ?? 0.5
+ } );
+
+ this.onChange = onChange;
+
+ const card = document.createElement( 'div' );
+ card.className = 'lut-card';
+
+ card.appendChild( this.createCardHeader( this.name, () => this.reset(), onRemove ) );
+
+ this.contrastControl = this.createSliderControl( {
+ key: 'contrast',
+ label: 'Contrast',
+ min: 0.2,
+ max: 2.0,
+ step: 0.01,
+ def: 1.0
+ } );
+
+ this.pivotControl = this.createSliderControl( {
+ key: 'contrastPivot',
+ label: 'Pivot',
+ min: 0.1,
+ max: 0.9,
+ step: 0.01,
+ def: 0.5
+ } );
+
+ card.appendChild( this.contrastControl );
+ card.appendChild( this.pivotControl );
+
+ this.domElement = card;
+
+ }
+
+ applyPixel( r, g, b, target = _tempRgb ) {
+
+ const { contrast, contrastPivot } = this.params;
+
+ if ( contrast === 1.0 ) {
+
+ target[ 0 ] = r;
+ target[ 1 ] = g;
+ target[ 2 ] = b;
+ return target;
+
+ }
+
+ target[ 0 ] = contrastPivot + ( r - contrastPivot ) * contrast;
+ target[ 1 ] = contrastPivot + ( g - contrastPivot ) * contrast;
+ target[ 2 ] = contrastPivot + ( b - contrastPivot ) * contrast;
+ return target;
+
+ }
+
+ reset() {
+
+ this.params.contrast = 1.0;
+ this.params.contrastPivot = 0.5;
+ this.updateUI();
+ this.onParamChange();
+
+ }
+
+ updateUI() {
+
+ this.contrastControl._setValue( this.params.contrast );
+ this.pivotControl._setValue( this.params.contrastPivot );
+
+ }
+
+}
diff --git a/examples/jsm/inspector/extensions/color-grading/modules/CurvesModule.js b/examples/jsm/inspector/extensions/color-grading/modules/CurvesModule.js
new file mode 100644
index 00000000000000..15f8e3a1f7f08d
--- /dev/null
+++ b/examples/jsm/inspector/extensions/color-grading/modules/CurvesModule.js
@@ -0,0 +1,109 @@
+import { Module } from './Module.js';
+import { CurveEditor } from '../CurveEditor.js';
+import { evaluateSpline } from '../LUTMath.js';
+
+const _tempRgb = [ 0, 0, 0 ];
+
+export class CurvesModule extends Module {
+
+ constructor( params = {}, onChange = null, onRemove = null, id = 'curves' ) {
+
+ const defaultCurves = {
+ rgb: [ { x: 0, y: 0 }, { x: 1, y: 1 } ],
+ red: [ { x: 0, y: 0 }, { x: 1, y: 1 } ],
+ green: [ { x: 0, y: 0 }, { x: 1, y: 1 } ],
+ blue: [ { x: 0, y: 0 }, { x: 1, y: 1 } ]
+ };
+
+ const initialCurves = params.curves ? params.curves : ( params.rgb ? params : defaultCurves );
+
+ super( id, 'Curves', { curves: JSON.parse( JSON.stringify( initialCurves ) ) } );
+
+ this.onChange = onChange;
+
+ const card = document.createElement( 'div' );
+ card.className = 'lut-card curves-card';
+
+ card.appendChild( this.createCardHeader( this.name, () => this.reset(), onRemove ) );
+
+ this.curveEditor = new CurveEditor( {
+ curves: this.params.curves,
+ onChange: ( updatedCurves ) => {
+
+ this.params.curves = updatedCurves;
+ this.onParamChange();
+
+ }
+ } );
+
+ card.appendChild( this.curveEditor.domElement );
+ this.domElement = card;
+
+ }
+
+ applyPixel( r, g, b, target = _tempRgb ) {
+
+ let cr = Math.max( 0, Math.min( 1, r ) );
+ let cg = Math.max( 0, Math.min( 1, g ) );
+ let cb = Math.max( 0, Math.min( 1, b ) );
+
+ const { rgb, red, green, blue } = this.params.curves;
+
+ if ( rgb && rgb.length > 0 ) {
+
+ cr = evaluateSpline( cr, rgb );
+ cg = evaluateSpline( cg, rgb );
+ cb = evaluateSpline( cb, rgb );
+
+ }
+
+ if ( red && red.length > 0 ) {
+
+ cr = evaluateSpline( cr, red );
+
+ }
+
+ if ( green && green.length > 0 ) {
+
+ cg = evaluateSpline( cg, green );
+
+ }
+
+ if ( blue && blue.length > 0 ) {
+
+ cb = evaluateSpline( cb, blue );
+
+ }
+
+ target[ 0 ] = Math.max( 0, Math.min( 1, cr ) );
+ target[ 1 ] = Math.max( 0, Math.min( 1, cg ) );
+ target[ 2 ] = Math.max( 0, Math.min( 1, cb ) );
+ return target;
+
+ }
+
+ reset() {
+
+ const defaultCurves = {
+ rgb: [ { x: 0, y: 0 }, { x: 1, y: 1 } ],
+ red: [ { x: 0, y: 0 }, { x: 1, y: 1 } ],
+ green: [ { x: 0, y: 0 }, { x: 1, y: 1 } ],
+ blue: [ { x: 0, y: 0 }, { x: 1, y: 1 } ]
+ };
+
+ this.params.curves = JSON.parse( JSON.stringify( defaultCurves ) );
+
+ this.curveEditor.curves = this.params.curves;
+ this.curveEditor.render();
+
+ this.onParamChange();
+
+ }
+
+ updateUI() {
+
+ this.curveEditor.setCurves( this.params.curves );
+
+ }
+
+}
diff --git a/examples/jsm/inspector/extensions/color-grading/modules/ExposureHueModule.js b/examples/jsm/inspector/extensions/color-grading/modules/ExposureHueModule.js
new file mode 100644
index 00000000000000..8b921cb27d84d1
--- /dev/null
+++ b/examples/jsm/inspector/extensions/color-grading/modules/ExposureHueModule.js
@@ -0,0 +1,110 @@
+import { Module } from './Module.js';
+import { rgbToHsv, hsvToRgb } from '../LUTMath.js';
+
+const _tempRgb = [ 0, 0, 0 ];
+
+export class ExposureHueModule extends Module {
+
+ constructor( params = {}, onChange = null, onRemove = null, id = 'exposureHue' ) {
+
+ super( id, 'Exposure & Hue', {
+ exposure: params.exposure ?? 0,
+ brightness: params.brightness ?? 0,
+ hueShift: params.hueShift ?? 0
+ } );
+
+ this.onChange = onChange;
+
+ const card = document.createElement( 'div' );
+ card.className = 'lut-card';
+
+ card.appendChild( this.createCardHeader( this.name, () => this.reset(), onRemove ) );
+
+ this.exposureControl = this.createSliderControl( {
+ key: 'exposure',
+ label: 'Exposure',
+ min: - 3,
+ max: 3,
+ step: 0.05,
+ def: 0
+ } );
+
+ this.hueShiftControl = this.createSliderControl( {
+ key: 'hueShift',
+ label: 'Hue (°)',
+ min: - 180,
+ max: 180,
+ step: 1,
+ def: 0
+ } );
+
+ card.appendChild( this.exposureControl );
+ card.appendChild( this.hueShiftControl );
+
+ this.domElement = card;
+
+ }
+
+ applyPixel( r, g, b, target = _tempRgb ) {
+
+ let cr = r;
+ let cg = g;
+ let cb = b;
+
+ const { exposure, brightness, hueShift } = this.params;
+
+ if ( exposure !== 0 ) {
+
+ const factor = Math.pow( 2, exposure );
+ cr *= factor;
+ cg *= factor;
+ cb *= factor;
+
+ }
+
+ if ( brightness !== 0 ) {
+
+ cr += brightness;
+ cg += brightness;
+ cb += brightness;
+
+ }
+
+ if ( hueShift !== 0 ) {
+
+ const [ origH, s, v ] = rgbToHsv( Math.max( 0, cr ), Math.max( 0, cg ), Math.max( 0, cb ) );
+ let h = ( origH + hueShift / 360 ) % 1;
+ if ( h < 0 ) h += 1;
+
+ const [ nr, ng, nb ] = hsvToRgb( h, s, v );
+ cr = nr;
+ cg = ng;
+ cb = nb;
+
+ }
+
+ target[ 0 ] = cr;
+ target[ 1 ] = cg;
+ target[ 2 ] = cb;
+ return target;
+
+ }
+
+ reset() {
+
+ this.params.exposure = 0;
+ this.params.brightness = 0;
+ this.params.hueShift = 0;
+ this.updateUI();
+ this.onParamChange();
+
+ }
+
+ updateUI() {
+
+ this.exposureControl._setValue( this.params.exposure );
+ this.hueShiftControl._setValue( this.params.hueShift );
+
+ }
+
+}
diff --git a/examples/jsm/inspector/extensions/color-grading/modules/ExposureModule.js b/examples/jsm/inspector/extensions/color-grading/modules/ExposureModule.js
new file mode 100644
index 00000000000000..2d89221b442bc7
--- /dev/null
+++ b/examples/jsm/inspector/extensions/color-grading/modules/ExposureModule.js
@@ -0,0 +1,73 @@
+import { Module } from './Module.js';
+
+const _tempRgb = [ 0, 0, 0 ];
+
+export class ExposureModule extends Module {
+
+ constructor( params = {}, onChange = null, onRemove = null, id = 'exposure' ) {
+
+ super( id, 'Exposure', {
+ exposure: params.exposure ?? 0
+ } );
+
+ this.onChange = onChange;
+
+ const card = document.createElement( 'div' );
+ card.className = 'lut-card';
+
+ card.appendChild( this.createCardHeader( this.name, () => this.reset(), onRemove ) );
+
+ this.exposureControl = this.createSliderControl( {
+ key: 'exposure',
+ label: 'Exposure',
+ min: - 3,
+ max: 3,
+ step: 0.05,
+ def: 0
+ } );
+
+ card.appendChild( this.exposureControl );
+
+ this.domElement = card;
+
+ }
+
+ applyPixel( r, g, b, target = _tempRgb ) {
+
+ let cr = r;
+ let cg = g;
+ let cb = b;
+
+ const { exposure } = this.params;
+
+ if ( exposure !== 0 ) {
+
+ const factor = Math.pow( 2, exposure );
+ cr *= factor;
+ cg *= factor;
+ cb *= factor;
+
+ }
+
+ target[ 0 ] = cr;
+ target[ 1 ] = cg;
+ target[ 2 ] = cb;
+ return target;
+
+ }
+
+ reset() {
+
+ this.params.exposure = 0;
+ this.updateUI();
+ this.onParamChange();
+
+ }
+
+ updateUI() {
+
+ this.exposureControl._setValue( this.params.exposure );
+
+ }
+
+}
diff --git a/examples/jsm/inspector/extensions/color-grading/modules/HueModule.js b/examples/jsm/inspector/extensions/color-grading/modules/HueModule.js
new file mode 100644
index 00000000000000..ba5edc3485301d
--- /dev/null
+++ b/examples/jsm/inspector/extensions/color-grading/modules/HueModule.js
@@ -0,0 +1,78 @@
+import { Module } from './Module.js';
+import { rgbToHsv, hsvToRgb } from '../LUTMath.js';
+
+const _tempRgb = [ 0, 0, 0 ];
+
+export class HueModule extends Module {
+
+ constructor( params = {}, onChange = null, onRemove = null, id = 'hue' ) {
+
+ super( id, 'Hue Shift', {
+ hueShift: params.hueShift ?? 0
+ } );
+
+ this.onChange = onChange;
+
+ const card = document.createElement( 'div' );
+ card.className = 'lut-card';
+
+ card.appendChild( this.createCardHeader( this.name, () => this.reset(), onRemove ) );
+
+ this.hueShiftControl = this.createSliderControl( {
+ key: 'hueShift',
+ label: 'Hue (°)',
+ min: - 180,
+ max: 180,
+ step: 1,
+ def: 0
+ } );
+
+ card.appendChild( this.hueShiftControl );
+
+ this.domElement = card;
+
+ }
+
+ applyPixel( r, g, b, target = _tempRgb ) {
+
+ let cr = r;
+ let cg = g;
+ let cb = b;
+
+ const { hueShift } = this.params;
+
+ if ( hueShift !== 0 ) {
+
+ const [ origH, s, v ] = rgbToHsv( Math.max( 0, cr ), Math.max( 0, cg ), Math.max( 0, cb ) );
+ let h = ( origH + hueShift / 360 ) % 1;
+ if ( h < 0 ) h += 1;
+
+ const [ nr, ng, nb ] = hsvToRgb( h, s, v );
+ cr = nr;
+ cg = ng;
+ cb = nb;
+
+ }
+
+ target[ 0 ] = cr;
+ target[ 1 ] = cg;
+ target[ 2 ] = cb;
+ return target;
+
+ }
+
+ reset() {
+
+ this.params.hueShift = 0;
+ this.updateUI();
+ this.onParamChange();
+
+ }
+
+ updateUI() {
+
+ this.hueShiftControl._setValue( this.params.hueShift );
+
+ }
+
+}
diff --git a/examples/jsm/inspector/extensions/color-grading/modules/ImportedCubeModule.js b/examples/jsm/inspector/extensions/color-grading/modules/ImportedCubeModule.js
new file mode 100644
index 00000000000000..34cde31300d3f8
--- /dev/null
+++ b/examples/jsm/inspector/extensions/color-grading/modules/ImportedCubeModule.js
@@ -0,0 +1,89 @@
+import { Module } from './Module.js';
+import { sample3DLUT } from '../LUTMath.js';
+
+const _tempRgb = [ 0, 0, 0 ];
+
+export class ImportedCubeModule extends Module {
+
+ constructor( id, cubeInfo = {}, onChange = null, onRemove = null ) {
+
+ super( id, cubeInfo.title || 'Imported LUT', {
+ title: cubeInfo.title || 'Imported LUT',
+ size: cubeInfo.size ?? 32,
+ dataLines: cubeInfo.dataLines || cubeInfo.data || [],
+ weight: cubeInfo.weight ?? 1.0
+ } );
+
+ this.onChange = onChange;
+ this.onRemove = onRemove;
+
+ const card = document.createElement( 'div' );
+ card.className = 'lut-card lut-imported-cube-card';
+
+ card.appendChild( this.createCardHeader(
+ this.name,
+ () => this.reset(),
+ this.onRemove ? () => this.onRemove( this.id ) : null
+ ) );
+
+ const body = document.createElement( 'div' );
+ body.style.cssText = 'display: flex; flex-direction: column; gap: 8px; width: 100%;';
+
+ const infoLabel = document.createElement( 'div' );
+ infoLabel.style.cssText = 'font-size: 10px; color: var(--text-secondary, #9a9aab); text-align: center; font-weight: 500;';
+ infoLabel.textContent = `3D LUT Size: ${this.params.size}³`;
+ body.appendChild( infoLabel );
+
+ this.weightControl = this.createSliderControl( {
+ key: 'weight',
+ label: 'Mix Weight',
+ min: 0,
+ max: 1.0,
+ step: 0.01,
+ def: 1.0
+ } );
+
+ body.appendChild( this.weightControl );
+ card.appendChild( body );
+
+ this.domElement = card;
+
+ }
+
+ applyPixel( r, g, b, target = _tempRgb ) {
+
+ const { dataLines, size, weight } = this.params;
+
+ if ( dataLines.length === 0 ) {
+
+ target[ 0 ] = r;
+ target[ 1 ] = g;
+ target[ 2 ] = b;
+ return target;
+
+ }
+
+ const [ lr, lg, lb ] = sample3DLUT( r, g, b, size, dataLines );
+
+ target[ 0 ] = r * ( 1 - weight ) + lr * weight;
+ target[ 1 ] = g * ( 1 - weight ) + lg * weight;
+ target[ 2 ] = b * ( 1 - weight ) + lb * weight;
+ return target;
+
+ }
+
+ reset() {
+
+ this.params.weight = 1.0;
+ this.updateUI();
+ this.onParamChange();
+
+ }
+
+ updateUI() {
+
+ this.weightControl._setValue( this.params.weight );
+
+ }
+
+}
diff --git a/examples/jsm/inspector/extensions/color-grading/modules/Module.js b/examples/jsm/inspector/extensions/color-grading/modules/Module.js
new file mode 100644
index 00000000000000..3dbb76f22a5f06
--- /dev/null
+++ b/examples/jsm/inspector/extensions/color-grading/modules/Module.js
@@ -0,0 +1,285 @@
+const _tempRgb = [ 0, 0, 0 ];
+
+/**
+ * Module.js - Base class for LUT 3D Generator Card Modules
+ */
+export class Module {
+
+ constructor( id, name, params = {} ) {
+
+ this.id = id;
+ this.name = name;
+ this.params = params;
+ this.enabled = true;
+ this.domElement = null;
+ this.onChange = null;
+ this.dragAndDrop = true;
+
+ }
+
+ /**
+ * Applies module transformation directly onto a Float32Array 3D LUT buffer.
+ * @param {Float32Array} buffer - Buffer of size x size x size x 4 RGBA elements
+ * @param {number} size - LUT grid dimension
+ * @returns {Float32Array}
+ */
+ apply( buffer ) {
+
+ if ( ! this.enabled ) return buffer;
+
+ const len = buffer.length;
+ const target = _tempRgb;
+
+ for ( let i = 0; i < len; i += 4 ) {
+
+ this.applyPixel( buffer[ i ], buffer[ i + 1 ], buffer[ i + 2 ], target );
+ buffer[ i ] = target[ 0 ];
+ buffer[ i + 1 ] = target[ 1 ];
+ buffer[ i + 2 ] = target[ 2 ];
+
+ }
+
+ return buffer;
+
+ }
+
+ /**
+ * Transforms a single RGB color pixel (0..1 range) into a reusable target array.
+ * Virtual method to be overridden by sub-classes.
+ */
+ applyPixel( r, g, b, target = _tempRgb ) {
+
+ target[ 0 ] = r;
+ target[ 1 ] = g;
+ target[ 2 ] = b;
+ return target;
+
+ }
+
+ /**
+ * Helper to create standard card header with title, reset button, and optional remove button.
+ */
+ createCardHeader( titleText, onReset, onRemove = null ) {
+
+ const header = document.createElement( 'div' );
+ header.className = 'lut-card-header';
+
+ const _createSvgIcon = ( svgPath, width = 11, height = 11, strokeWidth = 2.5 ) => {
+
+ const ns = 'http://www.w3.org/2000/svg';
+ const svg = document.createElementNS( ns, 'svg' );
+ svg.setAttribute( 'width', String( width ) );
+ svg.setAttribute( 'height', String( height ) );
+ svg.setAttribute( 'viewBox', '0 0 24 24' );
+ svg.setAttribute( 'fill', 'none' );
+ svg.setAttribute( 'stroke', 'currentColor' );
+ svg.setAttribute( 'stroke-width', String( strokeWidth ) );
+ svg.setAttribute( 'stroke-linecap', 'round' );
+ svg.setAttribute( 'stroke-linejoin', 'round' );
+ svg.style.display = 'block';
+ svg.innerHTML = svgPath;
+ return svg;
+
+ };
+
+ // Left Drag Handle + Reset Button
+ const dragHandle = document.createElement( 'div' );
+ dragHandle.className = 'lut-card-drag-handle';
+
+ if ( typeof onReset === 'function' ) {
+
+ const resetBtn = document.createElement( 'button' );
+ resetBtn.className = 'card-reset-btn lut-card-reset-btn';
+ resetBtn.appendChild( _createSvgIcon( '', 13, 13, 2.5 ) );
+ resetBtn.title = 'Reset Card';
+ resetBtn.onclick = ( e ) => {
+
+ e.stopPropagation();
+ onReset();
+
+ };
+
+ dragHandle.appendChild( resetBtn );
+
+ }
+
+ header.appendChild( dragHandle );
+
+ const titleLabel = document.createElement( 'span' );
+ titleLabel.className = 'lut-card-title';
+ titleLabel.textContent = titleText;
+ header.appendChild( titleLabel );
+
+ // Right Remove Button (where reset button was)
+ if ( onRemove ) {
+
+ const removeBtn = document.createElement( 'button' );
+ removeBtn.className = 'lut-card-remove-btn';
+ removeBtn.appendChild( _createSvgIcon( '', 13, 13, 2.5 ) );
+ removeBtn.title = 'Remove Card';
+ removeBtn.onclick = ( e ) => {
+
+ e.stopPropagation();
+ onRemove();
+
+ };
+
+ header.appendChild( removeBtn );
+
+ }
+
+ return header;
+
+ }
+
+ /**
+ * Helper to create parameter box with label, draggable number input, and range slider.
+ */
+ createSliderControl( { key, label, min, max, step, def } ) {
+
+ const paramBox = document.createElement( 'div' );
+ paramBox.className = 'param-control value-slider lut-param-box';
+
+ const top = document.createElement( 'div' );
+ top.className = 'lut-param-top';
+
+ const lbl = document.createElement( 'span' );
+ lbl.className = 'lut-param-label';
+ lbl.textContent = label;
+
+ const numInput = document.createElement( 'input' );
+ numInput.type = 'number';
+ numInput.min = min;
+ numInput.max = max;
+ numInput.step = step;
+ const initialVal = Number( this.params[ key ] !== undefined ? this.params[ key ] : def );
+ numInput.value = initialVal.toFixed( step < 0.1 ? 2 : 1 );
+ numInput.className = 'lut-num-input';
+
+ const slider = document.createElement( 'input' );
+ slider.type = 'range';
+ slider.className = 'inspector-slider';
+ slider.min = min;
+ slider.max = max;
+ slider.step = step;
+ slider.value = initialVal;
+
+ const updateVal = ( val ) => {
+
+ const clamped = Math.max( min, Math.min( max, val ) );
+ this.params[ key ] = clamped;
+ slider.value = clamped;
+ numInput.value = clamped.toFixed( step < 0.1 ? 2 : 1 );
+ this.onParamChange();
+
+ };
+
+ slider.oninput = () => {
+
+ updateVal( parseFloat( slider.value ) );
+
+ };
+
+ numInput.onchange = () => {
+
+ const val = parseFloat( numInput.value );
+ updateVal( isNaN( val ) ? def : val );
+
+ };
+
+ let isDragging = false;
+ let startY = 0;
+ let startVal = 0;
+
+ numInput.onpointerdown = ( e ) => {
+
+ isDragging = true;
+ startY = e.clientY;
+ startVal = parseFloat( numInput.value ) || def;
+ numInput.setPointerCapture( e.pointerId );
+
+ };
+
+ numInput.onpointermove = ( e ) => {
+
+ if ( isDragging ) {
+
+ const delta = ( startY - e.clientY ) * step;
+ updateVal( startVal + delta );
+
+ }
+
+ };
+
+ const stopDrag = ( e ) => {
+
+ if ( isDragging ) {
+
+ isDragging = false;
+ try {
+
+ numInput.releasePointerCapture( e.pointerId );
+
+ } catch ( _err ) { /* ignore */ }
+
+ }
+
+ };
+
+ numInput.onpointerup = stopDrag;
+ numInput.onpointercancel = stopDrag;
+
+ top.appendChild( lbl );
+ top.appendChild( numInput );
+ paramBox.appendChild( top );
+ paramBox.appendChild( slider );
+
+ paramBox._setValue = ( v ) => {
+
+ const formatted = Number( v ).toFixed( step < 0.1 ? 2 : 1 );
+ slider.value = v;
+ numInput.value = formatted;
+
+ };
+
+ return paramBox;
+
+ }
+
+ onParamChange() {
+
+ if ( this.onChange ) this.onChange( this );
+
+ }
+
+ reset() {
+
+ this.onParamChange();
+
+ }
+
+ updateUI() {
+
+ }
+
+ toJSON() {
+
+ return {
+ id: this.id,
+ params: JSON.parse( JSON.stringify( this.params ) )
+ };
+
+ }
+
+ fromJSON( json ) {
+
+ if ( json && json.params ) {
+
+ Object.assign( this.params, json.params );
+ this.updateUI();
+
+ }
+
+ }
+
+}
diff --git a/examples/jsm/inspector/extensions/color-grading/modules/OutputModule.js b/examples/jsm/inspector/extensions/color-grading/modules/OutputModule.js
new file mode 100644
index 00000000000000..97fb64fd7b645e
--- /dev/null
+++ b/examples/jsm/inspector/extensions/color-grading/modules/OutputModule.js
@@ -0,0 +1,101 @@
+import { Module } from './Module.js';
+
+const _tempRgb = [ 0, 0, 0 ];
+
+export class OutputModule extends Module {
+
+ constructor( params = {}, onChange = null ) {
+
+ const initialFileName = params.fileName || 'My Color Grading';
+
+ super( 'output', 'Output', {
+ fileName: initialFileName
+ } );
+
+ this.dragAndDrop = false;
+
+ this.onChange = onChange;
+
+ const card = document.createElement( 'div' );
+ card.className = 'lut-card output-card';
+
+ card.appendChild( this.createCardHeader( this.name, () => this.reset() ) );
+
+ // File Name input control
+ const nameBox = document.createElement( 'div' );
+ nameBox.className = 'param-control lut-param-box';
+ nameBox.style.cssText = 'width: 100%; box-sizing: border-box;';
+
+ this.nameInput = document.createElement( 'input' );
+ this.nameInput.type = 'text';
+ this.nameInput.className = 'lut-input lut-text-input';
+ this.nameInput.style.cssText = 'width: 100%; box-sizing: border-box; background: rgba(0,0,0,0.3); border: 1px solid rgba(74,74,90,0.5); border-radius: 4px; color: #e0e0e0; font-size: 11px; padding: 4px 6px; font-family: var(--font-family, sans-serif); text-align: center; outline: none; transition: border-color 0.15s;';
+ this.nameInput.value = this.params.fileName;
+ this.nameInput.placeholder = 'LUT name...';
+
+ this.nameInput.onfocus = () => {
+
+ this.nameInput.style.borderColor = 'var(--color-accent, #00aaff)';
+
+ };
+
+ this.nameInput.onblur = () => {
+
+ this.nameInput.style.borderColor = 'rgba(74,74,90,0.5)';
+
+ };
+
+ this.nameInput.oninput = () => {
+
+ const raw = this.nameInput.value;
+ this.params.fileName = raw || 'LUT3D';
+ this.onParamChange();
+
+ };
+
+ nameBox.appendChild( this.nameInput );
+ card.appendChild( nameBox );
+
+ const infoBox = document.createElement( 'div' );
+ infoBox.className = 'lut-output-info';
+ infoBox.style.cssText = 'display: flex; align-items: center; justify-content: center; text-align: center; padding: 4px 0 2px 0;';
+
+ this.resVal = document.createElement( 'span' );
+ this.resVal.style.cssText = 'font-size: 11px; color: var(--text-secondary, #9a9aab); font-weight: 400; letter-spacing: 0.2px;';
+ this.resVal.textContent = '3D LUT / TSL';
+
+ infoBox.appendChild( this.resVal );
+ card.appendChild( infoBox );
+
+ this.domElement = card;
+
+ }
+
+ applyPixel( r, g, b, target = _tempRgb ) {
+
+ target[ 0 ] = r;
+ target[ 1 ] = g;
+ target[ 2 ] = b;
+ return target;
+
+ }
+
+ reset() {
+
+ this.params.fileName = 'My Color Grading';
+ this.updateUI();
+ this.onParamChange();
+
+ }
+
+ updateUI() {
+
+ if ( this.nameInput ) {
+
+ this.nameInput.value = this.params.fileName;
+
+ }
+
+ }
+
+}
diff --git a/examples/jsm/inspector/extensions/color-grading/modules/RendererModule.js b/examples/jsm/inspector/extensions/color-grading/modules/RendererModule.js
new file mode 100644
index 00000000000000..07c67b37c9a270
--- /dev/null
+++ b/examples/jsm/inspector/extensions/color-grading/modules/RendererModule.js
@@ -0,0 +1,225 @@
+import { Module } from './Module.js';
+import {
+ NoToneMapping,
+ LinearToneMapping,
+ ReinhardToneMapping,
+ CineonToneMapping,
+ ACESFilmicToneMapping,
+ AgXToneMapping,
+ NeutralToneMapping
+} from 'three/webgpu';
+
+const _tempRgb = [ 0, 0, 0 ];
+
+export class RendererModule extends Module {
+
+ constructor( params = {}, onChange = null, onRendererChange = null ) {
+
+ const initialToneMapping = params.toneMapping ?? NoToneMapping;
+ const initialExposure = params.exposure ?? 1.0;
+
+ super( 'renderer', 'Renderer', {
+ toneMapping: initialToneMapping,
+ exposure: initialExposure
+ } );
+
+ this.dragAndDrop = false;
+
+ this.defaultToneMapping = initialToneMapping;
+ this.defaultExposure = initialExposure;
+
+ this.onChange = onChange;
+ this.onRendererChange = onRendererChange;
+
+ const card = document.createElement( 'div' );
+ card.className = 'lut-card renderer-card';
+
+ card.appendChild( this.createCardHeader( this.name, () => this.reset() ) );
+
+ // 1. Tone Mapping Dropdown
+ const tmBox = document.createElement( 'div' );
+ tmBox.className = 'param-control lut-param-box';
+
+ const tmTop = document.createElement( 'div' );
+ tmTop.className = 'lut-param-top';
+
+ const tmLabel = document.createElement( 'span' );
+ tmLabel.className = 'lut-param-label';
+ tmLabel.textContent = 'Tone Mapping';
+
+ this.toneMappingSelect = document.createElement( 'select' );
+ this.toneMappingSelect.className = 'lut-select lut-select-compact';
+
+ const toneMappingModes = [
+ { name: 'None', value: NoToneMapping },
+ { name: 'Linear', value: LinearToneMapping },
+ { name: 'Reinhard', value: ReinhardToneMapping },
+ { name: 'Cineon', value: CineonToneMapping },
+ { name: 'ACES Filmic', value: ACESFilmicToneMapping },
+ { name: 'AgX', value: AgXToneMapping },
+ { name: 'Neutral', value: NeutralToneMapping }
+ ];
+
+ toneMappingModes.forEach( mode => {
+
+ const opt = document.createElement( 'option' );
+ opt.value = mode.value;
+ opt.textContent = mode.name;
+ if ( mode.value === this.params.toneMapping ) opt.selected = true;
+ this.toneMappingSelect.appendChild( opt );
+
+ } );
+
+ this.toneMappingSelect.onchange = ( e ) => {
+
+ this.params.toneMapping = parseInt( e.target.value, 10 );
+ if ( this.onRendererChange ) this.onRendererChange( this );
+
+ };
+
+ tmTop.appendChild( tmLabel );
+ tmTop.appendChild( this.toneMappingSelect );
+ tmBox.appendChild( tmTop );
+ card.appendChild( tmBox );
+
+ // 2. Exposure Control
+ const expBox = document.createElement( 'div' );
+ expBox.className = 'param-control value-slider lut-param-box';
+
+ const expTop = document.createElement( 'div' );
+ expTop.className = 'lut-param-top';
+
+ const expLabel = document.createElement( 'span' );
+ expLabel.className = 'lut-param-label';
+ expLabel.textContent = 'Exposure';
+
+ this.exposureNumInput = document.createElement( 'input' );
+ this.exposureNumInput.type = 'number';
+ this.exposureNumInput.min = '0.1';
+ this.exposureNumInput.max = '5.0';
+ this.exposureNumInput.step = '0.05';
+ this.exposureNumInput.value = this.params.exposure.toFixed( 2 );
+ this.exposureNumInput.className = 'lut-num-input';
+
+ this.exposureSlider = document.createElement( 'input' );
+ this.exposureSlider.type = 'range';
+ this.exposureSlider.className = 'inspector-slider';
+ this.exposureSlider.min = '0.1';
+ this.exposureSlider.max = '5.0';
+ this.exposureSlider.step = '0.05';
+ this.exposureSlider.value = this.params.exposure;
+
+ const onExpChange = ( val ) => {
+
+ this.params.exposure = val;
+ if ( this.onRendererChange ) this.onRendererChange( this );
+
+ };
+
+ this.exposureSlider.oninput = () => {
+
+ const val = parseFloat( this.exposureSlider.value );
+ this.exposureNumInput.value = val.toFixed( 2 );
+ onExpChange( val );
+
+ };
+
+ this.exposureNumInput.onchange = () => {
+
+ const val = Math.max( 0.1, Math.min( 5.0, parseFloat( this.exposureNumInput.value ) || 1.0 ) );
+ this.exposureNumInput.value = val.toFixed( 2 );
+ this.exposureSlider.value = val;
+ onExpChange( val );
+
+ };
+
+ let isDragging = false;
+ let startX = 0;
+ let startVal = 1.0;
+
+ this.exposureNumInput.onmousedown = ( e ) => {
+
+ if ( document.activeElement === this.exposureNumInput ) return;
+
+ isDragging = true;
+ startX = e.clientX;
+ startVal = parseFloat( this.exposureSlider.value );
+
+ const onMouseMove = ( moveEvent ) => {
+
+ if ( ! isDragging ) return;
+ const deltaX = moveEvent.clientX - startX;
+ const newVal = Math.max( 0.1, Math.min( 5.0, startVal + deltaX * 0.01 ) );
+
+ this.exposureNumInput.value = newVal.toFixed( 2 );
+ this.exposureSlider.value = newVal;
+ onExpChange( newVal );
+
+ };
+
+ const onMouseUp = () => {
+
+ isDragging = false;
+ window.removeEventListener( 'mousemove', onMouseMove );
+ window.removeEventListener( 'mouseup', onMouseUp );
+
+ };
+
+ window.addEventListener( 'mousemove', onMouseMove );
+ window.addEventListener( 'mouseup', onMouseUp );
+
+ };
+
+ expTop.appendChild( expLabel );
+ expTop.appendChild( this.exposureNumInput );
+ expBox.appendChild( expTop );
+ expBox.appendChild( this.exposureSlider );
+ card.appendChild( expBox );
+
+ this.domElement = card;
+
+ }
+
+ applyPixel( r, g, b, target = _tempRgb ) {
+
+ target[ 0 ] = r;
+ target[ 1 ] = g;
+ target[ 2 ] = b;
+ return target;
+
+ }
+
+ reset() {
+
+ this.params.toneMapping = this.defaultToneMapping;
+ this.params.exposure = this.defaultExposure;
+ this.updateUI();
+
+ if ( this.onRendererChange ) this.onRendererChange( this );
+
+ }
+
+ updateUI() {
+
+ this.toneMappingSelect.value = String( this.params.toneMapping );
+ this.exposureNumInput.value = this.params.exposure.toFixed( 2 );
+ this.exposureSlider.value = String( this.params.exposure );
+
+ }
+
+ fromJSON( json ) {
+
+ if ( json && json.params ) {
+
+ if ( json.params.toneMapping !== undefined ) this.params.toneMapping = json.params.toneMapping;
+ if ( json.params.exposure !== undefined ) this.params.exposure = json.params.exposure;
+
+ }
+
+ this.updateUI();
+
+ if ( this.onRendererChange ) this.onRendererChange( this );
+
+ }
+
+}
diff --git a/examples/jsm/inspector/extensions/color-grading/modules/SatVibranceModule.js b/examples/jsm/inspector/extensions/color-grading/modules/SatVibranceModule.js
new file mode 100644
index 00000000000000..528dfeca7a0856
--- /dev/null
+++ b/examples/jsm/inspector/extensions/color-grading/modules/SatVibranceModule.js
@@ -0,0 +1,102 @@
+import { Module } from './Module.js';
+import { rgbToHsv, hsvToRgb } from '../LUTMath.js';
+
+const _tempRgb = [ 0, 0, 0 ];
+
+export class SatVibranceModule extends Module {
+
+ constructor( params = {}, onChange = null, onRemove = null, id = 'satVibrance' ) {
+
+ super( id, 'Sat & Vibrance', {
+ saturation: params.saturation ?? 1.0,
+ vibrance: params.vibrance ?? 0
+ } );
+
+ this.onChange = onChange;
+
+ const card = document.createElement( 'div' );
+ card.className = 'lut-card';
+
+ card.appendChild( this.createCardHeader( this.name, () => this.reset(), onRemove ) );
+
+ this.saturationControl = this.createSliderControl( {
+ key: 'saturation',
+ label: 'Saturation',
+ min: 0,
+ max: 2.0,
+ step: 0.01,
+ def: 1.0
+ } );
+
+ this.vibranceControl = this.createSliderControl( {
+ key: 'vibrance',
+ label: 'Vibrance',
+ min: - 1.0,
+ max: 1.0,
+ step: 0.01,
+ def: 0
+ } );
+
+ card.appendChild( this.saturationControl );
+ card.appendChild( this.vibranceControl );
+
+ this.domElement = card;
+
+ }
+
+ applyPixel( r, g, b, target = _tempRgb ) {
+
+ const { saturation, vibrance } = this.params;
+
+ if ( saturation === 1.0 && vibrance === 0 ) {
+
+ target[ 0 ] = r;
+ target[ 1 ] = g;
+ target[ 2 ] = b;
+ return target;
+
+ }
+
+ const [ h, origS, origV ] = rgbToHsv( Math.max( 0, r ), Math.max( 0, g ), Math.max( 0, b ) );
+ let s = origS;
+
+ if ( saturation !== 1.0 ) {
+
+ s *= saturation;
+
+ }
+
+ if ( vibrance !== 0 ) {
+
+ s += ( vibrance > 0 ? ( 1 - s ) : s ) * vibrance * 0.5;
+
+ }
+
+ s = Math.max( 0, Math.min( 1, s ) );
+ const v = Math.max( 0, Math.min( 1, origV ) );
+
+ const [ nr, ng, nb ] = hsvToRgb( h, s, v );
+ target[ 0 ] = nr;
+ target[ 1 ] = ng;
+ target[ 2 ] = nb;
+ return target;
+
+ }
+
+ reset() {
+
+ this.params.saturation = 1.0;
+ this.params.vibrance = 0;
+ this.updateUI();
+ this.onParamChange();
+
+ }
+
+ updateUI() {
+
+ this.saturationControl._setValue( this.params.saturation );
+ this.vibranceControl._setValue( this.params.vibrance );
+
+ }
+
+}
diff --git a/examples/jsm/inspector/extensions/color-grading/modules/SaturationModule.js b/examples/jsm/inspector/extensions/color-grading/modules/SaturationModule.js
new file mode 100644
index 00000000000000..9ea01ff89f81e4
--- /dev/null
+++ b/examples/jsm/inspector/extensions/color-grading/modules/SaturationModule.js
@@ -0,0 +1,76 @@
+import { Module } from './Module.js';
+import { rgbToHsv, hsvToRgb } from '../LUTMath.js';
+
+const _tempRgb = [ 0, 0, 0 ];
+
+export class SaturationModule extends Module {
+
+ constructor( params = {}, onChange = null, onRemove = null, id = 'saturation' ) {
+
+ super( id, 'Saturation', {
+ saturation: params.saturation ?? 1.0
+ } );
+
+ this.onChange = onChange;
+
+ const card = document.createElement( 'div' );
+ card.className = 'lut-card';
+
+ card.appendChild( this.createCardHeader( this.name, () => this.reset(), onRemove ) );
+
+ this.saturationControl = this.createSliderControl( {
+ key: 'saturation',
+ label: 'Saturation',
+ min: 0,
+ max: 2.0,
+ step: 0.01,
+ def: 1.0
+ } );
+
+ card.appendChild( this.saturationControl );
+
+ this.domElement = card;
+
+ }
+
+ applyPixel( r, g, b, target = _tempRgb ) {
+
+ const { saturation } = this.params;
+
+ if ( saturation === 1.0 ) {
+
+ target[ 0 ] = r;
+ target[ 1 ] = g;
+ target[ 2 ] = b;
+ return target;
+
+ }
+
+ const [ h, origS, origV ] = rgbToHsv( Math.max( 0, r ), Math.max( 0, g ), Math.max( 0, b ) );
+ let s = origS * saturation;
+ s = Math.max( 0, Math.min( 1, s ) );
+ const v = Math.max( 0, Math.min( 1, origV ) );
+
+ const [ nr, ng, nb ] = hsvToRgb( h, s, v );
+ target[ 0 ] = nr;
+ target[ 1 ] = ng;
+ target[ 2 ] = nb;
+ return target;
+
+ }
+
+ reset() {
+
+ this.params.saturation = 1.0;
+ this.updateUI();
+ this.onParamChange();
+
+ }
+
+ updateUI() {
+
+ this.saturationControl._setValue( this.params.saturation );
+
+ }
+
+}
diff --git a/examples/jsm/inspector/extensions/color-grading/modules/VibranceModule.js b/examples/jsm/inspector/extensions/color-grading/modules/VibranceModule.js
new file mode 100644
index 00000000000000..d822ed9422a4d8
--- /dev/null
+++ b/examples/jsm/inspector/extensions/color-grading/modules/VibranceModule.js
@@ -0,0 +1,76 @@
+import { Module } from './Module.js';
+import { rgbToHsv, hsvToRgb } from '../LUTMath.js';
+
+const _tempRgb = [ 0, 0, 0 ];
+
+export class VibranceModule extends Module {
+
+ constructor( params = {}, onChange = null, onRemove = null, id = 'vibrance' ) {
+
+ super( id, 'Vibrance', {
+ vibrance: params.vibrance ?? 0
+ } );
+
+ this.onChange = onChange;
+
+ const card = document.createElement( 'div' );
+ card.className = 'lut-card';
+
+ card.appendChild( this.createCardHeader( this.name, () => this.reset(), onRemove ) );
+
+ this.vibranceControl = this.createSliderControl( {
+ key: 'vibrance',
+ label: 'Vibrance',
+ min: - 1.0,
+ max: 1.0,
+ step: 0.01,
+ def: 0
+ } );
+
+ card.appendChild( this.vibranceControl );
+
+ this.domElement = card;
+
+ }
+
+ applyPixel( r, g, b, target = _tempRgb ) {
+
+ const { vibrance } = this.params;
+
+ if ( vibrance === 0 ) {
+
+ target[ 0 ] = r;
+ target[ 1 ] = g;
+ target[ 2 ] = b;
+ return target;
+
+ }
+
+ const [ h, origS, origV ] = rgbToHsv( Math.max( 0, r ), Math.max( 0, g ), Math.max( 0, b ) );
+ let s = origS + ( vibrance > 0 ? ( 1 - origS ) : origS ) * vibrance * 0.5;
+ s = Math.max( 0, Math.min( 1, s ) );
+ const v = Math.max( 0, Math.min( 1, origV ) );
+
+ const [ nr, ng, nb ] = hsvToRgb( h, s, v );
+ target[ 0 ] = nr;
+ target[ 1 ] = ng;
+ target[ 2 ] = nb;
+ return target;
+
+ }
+
+ reset() {
+
+ this.params.vibrance = 0;
+ this.updateUI();
+ this.onParamChange();
+
+ }
+
+ updateUI() {
+
+ this.vibranceControl._setValue( this.params.vibrance );
+
+ }
+
+}
diff --git a/examples/jsm/inspector/extensions/color-grading/modules/WhiteBalanceModule.js b/examples/jsm/inspector/extensions/color-grading/modules/WhiteBalanceModule.js
new file mode 100644
index 00000000000000..200ecdd09fa557
--- /dev/null
+++ b/examples/jsm/inspector/extensions/color-grading/modules/WhiteBalanceModule.js
@@ -0,0 +1,86 @@
+import { Module } from './Module.js';
+
+const _tempRgb = [ 0, 0, 0 ];
+
+export class WhiteBalanceModule extends Module {
+
+ constructor( params = {}, onChange = null, onRemove = null, id = 'whiteBalance' ) {
+
+ super( id, 'White Balance', {
+ temperature: params.temperature ?? 0,
+ tint: params.tint ?? 0
+ } );
+
+ this.onChange = onChange;
+
+ const card = document.createElement( 'div' );
+ card.className = 'lut-card';
+
+ card.appendChild( this.createCardHeader( this.name, () => this.reset(), onRemove ) );
+
+ this.temperatureControl = this.createSliderControl( {
+ key: 'temperature',
+ label: 'Temp (K)',
+ min: - 100,
+ max: 100,
+ step: 1,
+ def: 0
+ } );
+
+ this.tintControl = this.createSliderControl( {
+ key: 'tint',
+ label: 'Tint',
+ min: - 100,
+ max: 100,
+ step: 1,
+ def: 0
+ } );
+
+ card.appendChild( this.temperatureControl );
+ card.appendChild( this.tintControl );
+
+ this.domElement = card;
+
+ }
+
+ applyPixel( r, g, b, target = _tempRgb ) {
+
+ const { temperature, tint } = this.params;
+
+ if ( temperature === 0 && tint === 0 ) {
+
+ target[ 0 ] = r;
+ target[ 1 ] = g;
+ target[ 2 ] = b;
+ return target;
+
+ }
+
+ const temp = temperature / 100;
+ const t = tint / 100;
+
+ target[ 0 ] = r + temp * 0.1;
+ target[ 1 ] = g - t * 0.1;
+ target[ 2 ] = b - temp * 0.1;
+
+ return target;
+
+ }
+
+ reset() {
+
+ this.params.temperature = 0;
+ this.params.tint = 0;
+ this.updateUI();
+ this.onParamChange();
+
+ }
+
+ updateUI() {
+
+ this.temperatureControl._setValue( this.params.temperature );
+ this.tintControl._setValue( this.params.tint );
+
+ }
+
+}
diff --git a/examples/jsm/inspector/tabs/Settings.js b/examples/jsm/inspector/tabs/Settings.js
index a77f809d719df5..7154589d799101 100644
--- a/examples/jsm/inspector/tabs/Settings.js
+++ b/examples/jsm/inspector/tabs/Settings.js
@@ -3,6 +3,10 @@ import { WebGPURenderer, WebGLBackend, Node } from 'three/webgpu';
import { getItem, setItem } from '../Inspector.js';
const _extensions = [
+ {
+ name: 'Color Grading',
+ url: '../extensions/color-grading/ColorGrading.js'
+ },
{
name: 'TSL Graph',
url: '../extensions/tsl-graph/TSLGraphEditor.js'
diff --git a/examples/jsm/inspector/tabs/Timeline.js b/examples/jsm/inspector/tabs/Timeline.js
index b2a3ee93aab18b..b137a728915a60 100644
--- a/examples/jsm/inspector/tabs/Timeline.js
+++ b/examples/jsm/inspector/tabs/Timeline.js
@@ -45,8 +45,8 @@ class Timeline extends Tab {
this.baseTriangles = 0;
this.currentFrame = null;
this.isHierarchicalView = true;
- this.callBlocks = new WeakMap();
- this.fallbackBlocks = [];
+ this.activeBlocks = new Map();
+ this.domPool = [];
this.originalBackend = null;
this.originalMethods = new Map();
this.renderer = null;
@@ -507,8 +507,24 @@ class Timeline extends Tab {
this.timelineTrack.style.margin = '10px';
this.timelineTrack.style.marginTop = '8px';
this.timelineTrack.style.backgroundColor = 'var(--background-color)';
+ this.timelineTrack.style.position = 'relative';
mainArea.appendChild( this.timelineTrack );
+ this.timelineContent = document.createElement( 'div' );
+ this.timelineContent.style.position = 'relative';
+ this.timelineContent.style.width = '100%';
+ this.timelineTrack.appendChild( this.timelineContent );
+
+ this.timelineTrack.addEventListener( 'scroll', () => {
+
+ if ( ! this.isRecording && this.frames.length > 0 ) {
+
+ this.updateVisibleBlocks();
+
+ }
+
+ } );
+
container.appendChild( mainArea );
this.content.appendChild( container );
@@ -708,7 +724,9 @@ class Timeline extends Tab {
clear() {
this.frames = [];
- this.timelineTrack.innerHTML = '';
+ this.clearActiveBlocks();
+ this.timelineContent.innerHTML = '';
+ this.timelineContent.style.height = '0px';
this.playhead.style.display = 'none';
this.frameInfo.textContent = '';
this.baseTriangles = 0;
@@ -1331,7 +1349,9 @@ class Timeline extends Tab {
this.renderTimelineTrack( frame );
// Update UI texts
- const isCompact = this.profiler.panel.offsetWidth < 800;
+ const panelWidth = this.content.offsetWidth;
+
+ const isCompact = panelWidth < 800;
const frameLabel = isCompact ? '' : 'Frame: ';
const fpsSuffix = isCompact ? '' : ' FPS';
const callsSuffix = isCompact ? '' : ' calls';
@@ -1373,98 +1393,205 @@ class Timeline extends Tab {
}
- getCallBlock( call, fallbackIndex, instanceIndex = 0 ) {
+ createBlock() {
+
+ const block = document.createElement( 'div' );
+ block.style.display = 'flex';
+ block.style.alignItems = 'center';
+ block.style.padding = '4px 8px';
+ block.style.backgroundColor = 'rgba(255, 255, 255, 0.03)';
+ block.style.fontFamily = 'monospace';
+ block.style.fontSize = '12px';
+ block.style.color = 'var(--text-primary)';
+ block.style.overflow = 'hidden';
+ block.style.position = 'absolute';
+ block.style.left = '0';
+ block.style.right = '0';
+ block.style.height = '24px';
+ block.style.boxSizing = 'border-box';
+
+ block.arrow = document.createElement( 'span' );
+ block.arrow.style.fontSize = '10px';
+ block.arrow.style.marginRight = '8px';
+ block.arrow.style.cursor = 'pointer';
+ block.arrow.style.width = '35px';
+ block.arrow.style.textAlign = 'center';
+ block.arrow.style.flexShrink = '0';
+ block.arrow.style.whiteSpace = 'nowrap';
+ block.appendChild( block.arrow );
+
+ block.titleSpan = document.createElement( 'span' );
+ block.titleSpan.style.flex = '1';
+ block.titleSpan.style.minWidth = '0';
+ block.titleSpan.style.overflow = 'hidden';
+ block.titleSpan.style.textOverflow = 'ellipsis';
+ block.titleSpan.style.whiteSpace = 'nowrap';
+ block.appendChild( block.titleSpan );
+
+ block.addEventListener( 'click', ( e ) => {
+
+ if ( ! block._groupId ) return;
+
+ e.stopPropagation();
+
+ if ( this.collapsedGroups.has( block._groupId ) ) {
+
+ this.collapsedGroups.delete( block._groupId );
- const target = call.target;
- let block;
+ } else {
- if ( target && typeof target === 'object' ) {
+ this.collapsedGroups.add( block._groupId );
- let blocks = this.callBlocks.get( target );
+ }
- if ( ! blocks ) {
+ this.renderTimelineTrack( this.frames[ this.selectedFrameIndex ] );
- blocks = [];
- this.callBlocks.set( target, blocks );
+ } );
+
+ return block;
+
+ }
+
+ clearActiveBlocks() {
+
+ if ( this.activeBlocks ) {
+
+ for ( const block of this.activeBlocks.values() ) {
+
+ block.remove();
+ this.domPool.push( block );
}
- block = blocks[ instanceIndex ];
+ this.activeBlocks.clear();
- } else {
+ }
- block = this.fallbackBlocks[ fallbackIndex ];
+ }
+
+ updateVisibleBlocks() {
+
+ if ( ! this.flatList || this.flatList.length === 0 ) {
+
+ this.clearActiveBlocks();
+ return;
}
- if ( ! block ) {
+ const ROW_HEIGHT = 26;
+ const BUFFER_ROWS = 5;
- block = document.createElement( 'div' );
- block.style.display = 'flex';
- block.style.alignItems = 'center';
- block.style.padding = '4px 8px';
- block.style.margin = '2px 0';
- block.style.backgroundColor = 'rgba(255, 255, 255, 0.03)';
- block.style.fontFamily = 'monospace';
- block.style.fontSize = '12px';
- block.style.color = 'var(--text-primary)';
- block.style.overflow = 'hidden';
+ const scrollTop = this.timelineTrack.scrollTop;
+ const viewportHeight = this.timelineTrack.clientHeight;
- block.arrow = document.createElement( 'span' );
- block.arrow.style.fontSize = '10px';
- block.arrow.style.marginRight = '8px';
- block.arrow.style.cursor = 'pointer';
- block.arrow.style.width = '35px';
- block.arrow.style.textAlign = 'center';
- block.arrow.style.flexShrink = '0';
- block.arrow.style.whiteSpace = 'nowrap';
- block.appendChild( block.arrow );
+ let startIndex = Math.floor( scrollTop / ROW_HEIGHT ) - BUFFER_ROWS;
+ let endIndex = Math.ceil( ( scrollTop + viewportHeight ) / ROW_HEIGHT ) + BUFFER_ROWS;
- block.titleSpan = document.createElement( 'span' );
- block.titleSpan.style.flex = '1';
- block.titleSpan.style.minWidth = '0';
- block.titleSpan.style.overflow = 'hidden';
- block.titleSpan.style.textOverflow = 'ellipsis';
- block.titleSpan.style.whiteSpace = 'nowrap';
- block.appendChild( block.titleSpan );
+ startIndex = Math.max( 0, startIndex );
+ endIndex = Math.min( this.flatList.length - 1, endIndex );
- block.addEventListener( 'click', ( e ) => {
+ const nextActiveBlocks = new Map();
- if ( ! block._groupId ) return;
+ for ( const [ index, block ] of this.activeBlocks.entries() ) {
- e.stopPropagation();
+ if ( index < startIndex || index > endIndex ) {
- if ( this.collapsedGroups.has( block._groupId ) ) {
+ block.remove();
+ this.domPool.push( block );
- this.collapsedGroups.delete( block._groupId );
+ } else {
- } else {
+ nextActiveBlocks.set( index, block );
- this.collapsedGroups.add( block._groupId );
+ }
+
+ }
+
+ this.activeBlocks = nextActiveBlocks;
+
+ for ( let i = startIndex; i <= endIndex; i ++ ) {
+
+ let block = this.activeBlocks.get( i );
+
+ if ( ! block ) {
+
+ block = this.domPool.pop() || this.createBlock();
+ this.timelineContent.appendChild( block );
+ this.activeBlocks.set( i, block );
+
+ }
+
+ const item = this.flatList[ i ];
+ const call = item.call;
+
+ block.style.top = ( i * ROW_HEIGHT ) + 'px';
+ block.style.marginLeft = ( item.indent * 24 ) + 'px';
+ block.style.borderLeft = '4px solid ' + this.getColorForMethod( call.method );
+ block._groupId = item.groupId;
+
+ const directInfoIcon = block.querySelector( ':scope > .info-icon' );
+ if ( directInfoIcon ) {
+
+ directInfoIcon.remove();
+
+ }
+
+ block.titleSpan.textContent = '';
+
+ const methodSpan = document.createElement( 'span' );
+ methodSpan.textContent = call.method;
+ block.titleSpan.appendChild( methodSpan );
+
+ if ( call.details ) {
+
+ let tooltipText = `### ${call.method}\n`;
+ for ( const key in call.details ) {
+
+ if ( call.details[ key ] !== undefined ) {
+
+ tooltipText += `**${key}**: ${call.details[ key ]}\n`;
+
+ }
}
- this.renderTimelineTrack( this.frames[ this.selectedFrameIndex ] );
+ const infoIcon = info( block.titleSpan, tooltipText );
+ infoIcon.style.flexShrink = '0';
+ infoIcon.style.marginLeft = '6px';
+ infoIcon.style.display = 'inline-flex';
+ infoIcon.style.verticalAlign = 'middle';
- } );
+ }
- if ( target && typeof target === 'object' ) {
+ const detailsAndCountSpan = document.createElement( 'span' );
+ let detailsAndCountHTML = ( call.formatedDetails ? call.formatedDetails : '' );
+ if ( call.count > 1 ) {
- this.callBlocks.get( target )[ instanceIndex ] = block;
+ detailsAndCountHTML += ` ( ${call.count} )`;
- } else {
+ }
+
+ if ( detailsAndCountHTML ) {
- this.fallbackBlocks[ fallbackIndex ] = block;
+ detailsAndCountSpan.innerHTML = detailsAndCountHTML;
+ block.titleSpan.appendChild( detailsAndCountSpan );
}
- }
+ if ( item.groupId ) {
- block.style.cursor = 'default';
- block._groupId = null;
- block.arrow.style.display = 'none';
+ block.style.cursor = 'pointer';
+ block.arrow.style.display = 'inline-block';
+ block.arrow.textContent = item.isCollapsed ? '[ + ]' : '[ - ]';
- return block;
+ } else {
+
+ block.style.cursor = 'default';
+ block.arrow.style.display = 'none';
+
+ }
+
+ }
}
@@ -1472,25 +1599,23 @@ class Timeline extends Tab {
if ( this.isRecording ) return;
+ this.flatList = [];
+
if ( ! frame || frame.calls.length === 0 ) {
- this.timelineTrack.innerHTML = '';
+ this.clearActiveBlocks();
+ this.timelineContent.innerHTML = '';
+ this.timelineContent.style.height = '0px';
return;
}
- // Track collapsed states
if ( ! this.collapsedGroups ) {
this.collapsedGroups = new Set();
}
- let blockIndex = 0;
- const trackChildren = this.timelineTrack.children;
- let childIndex = 0;
- const instanceCounts = new WeakMap();
-
if ( this.isHierarchicalView ) {
const groupedCalls = [];
@@ -1516,15 +1641,12 @@ class Timeline extends Tab {
}
let currentIndent = 0;
- const indentSize = 24;
-
- // Stack to keep track of parent elements and their collapsed state
- const elementStack = [ { element: this.timelineTrack, isCollapsed: false, id: '', beginCount: 0 } ];
+ const elementStack = [ { isCollapsed: false, id: '', beginCount: 0 } ];
+ const instanceCounts = new WeakMap();
for ( let i = 0; i < groupedCalls.length; i ++ ) {
const call = groupedCalls[ i ];
-
let instanceIndex = 0;
if ( call.target && typeof call.target === 'object' ) {
@@ -1534,90 +1656,35 @@ class Timeline extends Tab {
}
- const block = this.getCallBlock( call, blockIndex ++, instanceIndex );
- block.style.marginLeft = ( currentIndent * indentSize ) + 'px';
- block.style.borderLeft = '4px solid ' + this.getColorForMethod( call.method );
-
- // Clean up any old info-icon directly under block
- const directInfoIcon = block.querySelector( ':scope > .info-icon' );
- if ( directInfoIcon ) {
-
- directInfoIcon.remove();
-
- }
-
- // Build titleSpan content
- block.titleSpan.textContent = '';
-
- const methodSpan = document.createElement( 'span' );
- methodSpan.textContent = call.method;
- block.titleSpan.appendChild( methodSpan );
-
- if ( call.details ) {
-
- let tooltipText = `### ${call.method}\n`;
- for ( const key in call.details ) {
-
- if ( call.details[ key ] !== undefined ) {
-
- tooltipText += `**${key}**: ${call.details[ key ]}\n`;
-
- }
-
- }
-
- const infoIcon = info( block.titleSpan, tooltipText );
- infoIcon.style.flexShrink = '0';
- infoIcon.style.marginLeft = '6px';
- infoIcon.style.display = 'inline-flex';
- infoIcon.style.verticalAlign = 'middle';
-
- }
-
- const detailsAndCountSpan = document.createElement( 'span' );
- let detailsAndCountHTML = ( call.formatedDetails ? call.formatedDetails : '' );
- if ( call.count > 1 ) {
-
- detailsAndCountHTML += ` ( ${call.count} )`;
+ const currentParent = elementStack[ elementStack.length - 1 ];
- }
+ let groupId = null;
+ let isCollapsed = false;
- if ( detailsAndCountHTML ) {
+ if ( call.method.startsWith( 'begin' ) ) {
- detailsAndCountSpan.innerHTML = detailsAndCountHTML;
- block.titleSpan.appendChild( detailsAndCountSpan );
+ const beginIndex = currentParent.beginCount ++;
+ groupId = currentParent.id + '/' + call.method + '-' + beginIndex;
+ isCollapsed = this.collapsedGroups.has( groupId );
}
- const currentParent = elementStack[ elementStack.length - 1 ];
-
- // Only add to DOM if parent is not collapsed
if ( ! currentParent.isCollapsed ) {
- if ( trackChildren[ childIndex ] !== block ) {
-
- this.timelineTrack.insertBefore( block, trackChildren[ childIndex ] );
-
- }
-
- childIndex ++;
+ this.flatList.push( {
+ call,
+ indent: currentIndent,
+ groupId,
+ isCollapsed,
+ instanceIndex
+ } );
}
if ( call.method.startsWith( 'begin' ) ) {
- const beginIndex = currentParent.beginCount ++;
- const groupId = currentParent.id + '/' + call.method + '-' + beginIndex;
- const isCollapsed = this.collapsedGroups.has( groupId );
-
- block._groupId = groupId;
- block.style.cursor = 'pointer';
-
- block.arrow.style.display = 'inline-block';
- block.arrow.textContent = isCollapsed ? '[ + ]' : '[ - ]';
-
currentIndent ++;
- elementStack.push( { element: block, isCollapsed: currentParent.isCollapsed || isCollapsed, id: groupId, beginCount: 0 } );
+ elementStack.push( { isCollapsed: currentParent.isCollapsed || isCollapsed, id: groupId, beginCount: 0 } );
} else if ( call.method.startsWith( 'finish' ) ) {
@@ -1649,36 +1716,21 @@ class Timeline extends Tab {
const call = sortedCalls[ i ];
- const block = this.getCallBlock( call, blockIndex ++ );
- block.style.marginLeft = '0px';
- block.style.borderLeft = '4px solid ' + this.getColorForMethod( call.method );
-
- const infoIcon = block.querySelector( '.info-icon' );
- if ( infoIcon ) {
-
- infoIcon.remove();
-
- }
-
- block.titleSpan.innerHTML = call.method + ( call.count > 1 ? ` ( ${call.count} )` : '' );
-
- if ( trackChildren[ childIndex ] !== block ) {
-
- this.timelineTrack.insertBefore( block, trackChildren[ childIndex ] );
-
- }
-
- childIndex ++;
+ this.flatList.push( {
+ call,
+ indent: 0,
+ groupId: null,
+ isCollapsed: false,
+ instanceIndex: 0
+ } );
}
}
- while ( this.timelineTrack.children.length > childIndex ) {
-
- this.timelineTrack.removeChild( this.timelineTrack.lastChild );
-
- }
+ const ROW_HEIGHT = 26;
+ this.timelineContent.style.height = ( this.flatList.length * ROW_HEIGHT ) + 'px';
+ this.updateVisibleBlocks();
}
diff --git a/examples/jsm/inspector/ui/Profiler.js b/examples/jsm/inspector/ui/Profiler.js
index 4d6a4cd8fea92e..a1d9675a0194d1 100644
--- a/examples/jsm/inspector/ui/Profiler.js
+++ b/examples/jsm/inspector/ui/Profiler.js
@@ -199,6 +199,7 @@ export class Profiler extends EventDispatcher {
constrainDetachedWindows();
constrainMainPanel();
this.checkHeaderScroll();
+ this.notifyLayoutChange();
} );
@@ -601,11 +602,18 @@ export class Profiler extends EventDispatcher {
}
+ // Set profiler reference
+ tab.profiler = this;
+
// Update panel size when tabs change
this.updatePanelSize();
- // Set profiler reference
- tab.profiler = this;
+ // If newly added tab matches activeTabId from saved layout, activate it
+ if ( this.activeTabId && tab.id === this.activeTabId ) {
+
+ this.setActiveTab( tab.id );
+
+ }
}
@@ -2215,6 +2223,24 @@ export class Profiler extends EventDispatcher {
}
+ this.notifyLayoutChange();
+
+ }
+
+ isVertical() {
+
+ return this.position === 'left' || this.position === 'right' ||
+ ( this.panel && ( this.panel.classList.contains( 'position-left' ) || this.panel.classList.contains( 'position-right' ) ) );
+
+ }
+
+ notifyLayoutChange() {
+
+ const isVert = this.isVertical();
+
+ this.dispatchEvent( { type: 'orientationchange', position: this.position, isVertical: isVert } );
+ this.dispatchEvent( { type: 'layoutchange', position: this.position, isVertical: isVert } );
+
}
}
diff --git a/examples/jsm/inspector/ui/Style.js b/examples/jsm/inspector/ui/Style.js
index c1f9d5fde54afb..861ef6195a2350 100644
--- a/examples/jsm/inspector/ui/Style.js
+++ b/examples/jsm/inspector/ui/Style.js
@@ -408,6 +408,8 @@ export class Style {
margin-left: 6px;
cursor: help;
position: relative;
+ vertical-align: middle;
+ top: -1px;
}
.info-icon.active {
diff --git a/examples/jsm/inspector/ui/Tab.js b/examples/jsm/inspector/ui/Tab.js
index 0b77b35f6ceaa3..9564ee078b0aa1 100644
--- a/examples/jsm/inspector/ui/Tab.js
+++ b/examples/jsm/inspector/ui/Tab.js
@@ -264,4 +264,6 @@ export class Tab extends EventDispatcher {
}
+ dispose() { }
+
}
diff --git a/examples/jsm/tsl/WebGLNodesHandler.js b/examples/jsm/tsl/WebGLNodesHandler.js
index 584b93d0e2c176..57e653979f5c5e 100644
--- a/examples/jsm/tsl/WebGLNodesHandler.js
+++ b/examples/jsm/tsl/WebGLNodesHandler.js
@@ -34,7 +34,6 @@ import {
// - Storage textures not supported
// - Fog / environment do not automatically update - must call "dispose"
// - instanced mesh geometry cannot be shared
-// - Node materials cannot be used with "compile" function
// hash any object parameters that will impact the resulting shader so we can force
// a program update
@@ -88,6 +87,15 @@ class WebGLNodeBuilder extends GLSLNodeBuilder {
}
+const _lights = new Set();
+let _camera = null;
+
+function collectLight( child ) {
+
+ if ( child.isLight && child.layers.test( _camera.layers ) ) _lights.add( child );
+
+}
+
// produce and update reusable nodes for a scene
class SceneContext {
@@ -96,6 +104,7 @@ class SceneContext {
// TODO: can / should we update the fog and environment node every frame for recompile?
this.renderer = renderer;
this.scene = scene;
+ this.sceneLights = [];
this.lightsNode = renderer.lighting.getNode( scene );
this.fogNode = null;
this.environmentNode = null;
@@ -108,27 +117,29 @@ class SceneContext {
const { lightsNode, environmentNode, fogNode } = this;
const lightsHash = lightsNode.getCacheKey();
- const envHash = environmentNode ? environmentNode.getCacheKey : 0;
+ const envHash = environmentNode ? environmentNode.getCacheKey() : 0;
const fogHash = fogNode ? fogNode.getCacheKey() : 0;
return NodeUtils.hashArray( [ lightsHash, envHash, fogHash ] );
}
- update() {
+ update( object, camera ) {
- const { scene, lightsNode } = this;
+ const { scene, lightsNode, sceneLights } = this;
// update lighting
- const sceneLights = [];
- scene.traverse( object => {
+ _camera = camera;
+ _lights.clear();
- if ( object.isLight ) {
+ scene.traverseVisible( collectLight );
- sceneLights.push( object );
+ // compile() can receive an object that has not been added to the target scene yet.
+ if ( object !== scene ) object.traverseVisible( collectLight );
- }
+ _camera = null;
- } );
+ sceneLights.length = 0;
+ sceneLights.push( ..._lights );
lightsNode.setLights( sceneLights );
@@ -402,38 +413,55 @@ export class WebGLNodesHandler {
}
- renderStart( scene, camera ) {
+ setupNodeMaterial( material ) {
+
+ if ( material && material.isNodeMaterial ) {
+
+ material.customProgramCacheKey = this.customProgramCacheKeyCallback;
+ material.onBeforeRender = this.onBeforeRenderCallback;
+
+ }
+
+ }
+
+ renderStart( scene, camera, targetScene = scene ) {
const { nodeFrame, renderStack, renderer, sceneContexts } = this;
nodeFrame.update();
nodeFrame.camera = camera;
- nodeFrame.scene = scene;
+ nodeFrame.scene = targetScene;
nodeFrame.frameId ++;
- let sceneContext = sceneContexts.get( scene );
+ let sceneContext = sceneContexts.get( targetScene );
if ( ! sceneContext ) {
- sceneContext = new SceneContext( renderer, scene );
- sceneContexts.set( scene, sceneContext );
+ sceneContext = new SceneContext( renderer, targetScene );
+ sceneContexts.set( targetScene, sceneContext );
}
- sceneContext.update();
+ sceneContext.update( scene, camera );
renderStack.push( { sceneContext, camera } );
// ensure all node material callbacks are initialized before
// traversal and build
- const {
- customProgramCacheKeyCallback,
- onBeforeRenderCallback,
- } = this;
-
scene.traverse( object => {
- if ( object.material && object.material.isNodeMaterial ) {
+ const material = object.material;
+
+ if ( material === undefined ) return;
+
+ if ( Array.isArray( material ) ) {
+
+ for ( let i = 0; i < material.length; i ++ ) {
+
+ this.setupNodeMaterial( material[ i ] );
- object.material.customProgramCacheKey = customProgramCacheKeyCallback;
- object.material.onBeforeRender = onBeforeRenderCallback;
+ }
+
+ } else {
+
+ this.setupNodeMaterial( material );
}
@@ -458,6 +486,12 @@ export class WebGLNodesHandler {
}
+ setObject( object ) {
+
+ this.nodeFrame.object = object;
+
+ }
+
build( material, object, parameters ) {
const {
diff --git a/examples/webgl_tsl_clearcoat.html b/examples/webgl_tsl_clearcoat.html
index 3be41f282d4ae8..1ccb6252c2a305 100644
--- a/examples/webgl_tsl_clearcoat.html
+++ b/examples/webgl_tsl_clearcoat.html
@@ -58,12 +58,11 @@
scene = new THREE.Scene();
group = new THREE.Group();
- scene.add( group );
new HDRCubeTextureLoader()
.setPath( 'textures/cube/pisaHDR/' )
.load( [ 'px.hdr', 'nx.hdr', 'py.hdr', 'ny.hdr', 'pz.hdr', 'nz.hdr' ],
- function ( texture ) {
+ async function ( texture ) {
const geometry = new THREE.SphereGeometry( .8, 64, 32 );
@@ -109,6 +108,8 @@
let mesh = new THREE.Mesh( geometry, material );
mesh.position.x = - 1;
mesh.position.y = 1;
+ mesh.castShadow = true;
+ mesh.receiveShadow = true;
group.add( mesh );
// fibers
@@ -123,6 +124,8 @@
mesh = new THREE.Mesh( geometry, material );
mesh.position.x = 1;
mesh.position.y = 1;
+ mesh.castShadow = true;
+ mesh.receiveShadow = true;
group.add( mesh );
// golf
@@ -140,6 +143,8 @@
mesh = new THREE.Mesh( geometry, material );
mesh.position.x = - 1;
mesh.position.y = - 1;
+ mesh.castShadow = true;
+ mesh.receiveShadow = true;
group.add( mesh );
// clearcoat + normalmap
@@ -158,6 +163,8 @@
mesh = new THREE.Mesh( geometry, material );
mesh.position.x = 1;
mesh.position.y = - 1;
+ mesh.castShadow = true;
+ mesh.receiveShadow = true;
group.add( mesh );
//
@@ -165,6 +172,12 @@
scene.background = texture;
scene.environment = texture;
+ // wait until the models can be added to the scene without blocking due to shader compilation
+
+ await renderer.compileAsync( group, camera, scene );
+
+ scene.add( group );
+
}
);
@@ -173,14 +186,17 @@
particleLight = new THREE.Mesh(
new THREE.SphereGeometry( .05, 8, 8 ),
- new THREE.MeshBasicMaterial( { color: 0xffffff } )
+ new THREE.MeshBasicNodeMaterial( { color: 0xffffff } )
);
scene.add( particleLight );
- particleLight.add( new THREE.PointLight( 0xffffff, 30 ) );
+ const pointLight = new THREE.PointLight( 0xffffff, 30 );
+ pointLight.castShadow = true;
+ particleLight.add( pointLight );
renderer = new WebGLRenderer( { antialias: true } );
renderer.setNodesHandler( new WebGLNodesHandler() );
+ renderer.shadowMap.enabled = true;
renderer.setPixelRatio( window.devicePixelRatio );
renderer.setSize( window.innerWidth, window.innerHeight );
renderer.setAnimationLoop( animate );
diff --git a/examples/webgpu_compute_particles_rain.html b/examples/webgpu_compute_particles_rain.html
index 5b65c1e37c915a..d562293e0cf4a0 100644
--- a/examples/webgpu_compute_particles_rain.html
+++ b/examples/webgpu_compute_particles_rain.html
@@ -106,6 +106,7 @@
const position = positionBuffer.element( instanceIndex );
const velocity = velocityBuffer.element( instanceIndex );
+ const ripplePosition = ripplePositionBuffer.element( instanceIndex );
const rippleTime = rippleTimeBuffer.element( instanceIndex );
const randX = hash( instanceIndex );
@@ -118,6 +119,10 @@
velocity.y = randX.mul( - .04 ).add( - .2 );
+ ripplePosition.x = randZ.mul( 100 ).add( - 50 );
+ ripplePosition.y = - 1;
+ ripplePosition.z = randY.mul( 100 ).add( - 50 );
+
rippleTime.x = 1000;
} )().compute( maxParticleCount );
diff --git a/src/renderers/WebGLRenderer.js b/src/renderers/WebGLRenderer.js
index a5dd5e36c73871..2f96342d87ad25 100644
--- a/src/renderers/WebGLRenderer.js
+++ b/src/renderers/WebGLRenderer.js
@@ -1353,6 +1353,8 @@ class WebGLRenderer {
function prepareMaterial( material, scene, object ) {
+ if ( _nodesHandler !== null && material.isNodeMaterial ) _nodesHandler.setObject( object );
+
if ( material.transparent === true && material.side === DoubleSide && material.forceSinglePass === false ) {
material.side = BackSide;
@@ -1388,6 +1390,7 @@ class WebGLRenderer {
this.compile = function ( scene, camera, targetScene = null ) {
if ( targetScene === null ) targetScene = scene;
+ if ( _nodesHandler !== null ) _nodesHandler.renderStart( scene, camera, targetScene );
currentRenderState = renderStates.get( targetScene );
currentRenderState.init( camera );
@@ -1434,6 +1437,10 @@ class WebGLRenderer {
currentRenderState.setupLights();
+ // node materials reference the shadow map when they are built, so it must exist by now
+
+ if ( _nodesHandler !== null ) shadowMap.render( currentRenderState.state.shadowsArray, targetScene, camera );
+
// Only initialize materials in the new scene, not the targetScene.
const materials = new Set();
@@ -1473,6 +1480,7 @@ class WebGLRenderer {
} );
currentRenderState = renderStateStack.pop();
+ if ( _nodesHandler !== null ) _nodesHandler.renderEnd();
return materials;
diff --git a/test/e2e/deterministic-injection.js b/test/e2e/deterministic-injection.js
index 14def3a2e643b4..a163989e597bdd 100644
--- a/test/e2e/deterministic-injection.js
+++ b/test/e2e/deterministic-injection.js
@@ -30,22 +30,17 @@
if ( window._renderFinished === true ) return;
- if ( window._renderStarted === false ) {
+ const intervalId = setInterval( function () {
- const intervalId = setInterval( function () {
+ if ( window._renderStarted === true ) {
- if ( window._renderStarted === true ) {
+ clearInterval( intervalId );
+ window._renderFinished = true;
+ cb( now() );
- cb( now() );
+ }
- clearInterval( intervalId );
- window._renderFinished = true;
-
- }
-
- }, 100 );
-
- }
+ }, 100 );
};