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 ); };