diff --git a/examples/assets/cube-luts/lut-blue.png b/examples/assets/cube-luts/lut-blue.png new file mode 100644 index 00000000000..0d16c8febee Binary files /dev/null and b/examples/assets/cube-luts/lut-blue.png differ diff --git a/examples/src/examples/graphics/hdr.controls.mjs b/examples/src/examples/graphics/hdr.controls.mjs index d47cfca31eb..55a350384fc 100644 --- a/examples/src/examples/graphics/hdr.controls.mjs +++ b/examples/src/examples/graphics/hdr.controls.mjs @@ -5,7 +5,7 @@ import * as pc from 'playcanvas'; * @returns {JSX.Element} The returned JSX Element. */ export const controls = ({ observer, ReactPCUI, React, jsx, fragment }) => { - const { BindingTwoWay, BooleanInput, SelectInput, LabelGroup, Panel } = ReactPCUI; + const { BindingTwoWay, BooleanInput, SelectInput, LabelGroup, Panel, SliderInput } = ReactPCUI; return fragment( jsx( Panel, @@ -36,6 +36,17 @@ export const controls = ({ observer, ReactPCUI, React, jsx, fragment }) => { { v: pc.TONEMAP_NEUTRAL, t: 'NEUTRAL' } ] }) + ), + jsx( + LabelGroup, + { text: 'LUT Intensity' }, + jsx(SliderInput, { + binding: new BindingTwoWay(), + link: { observer, path: 'data.colorLutIntensity' }, + min: 0, + max: 1, + precision: 2 + }) ) ); }; diff --git a/examples/src/examples/graphics/hdr.example.mjs b/examples/src/examples/graphics/hdr.example.mjs index 29a0f314312..46d7371a599 100644 --- a/examples/src/examples/graphics/hdr.example.mjs +++ b/examples/src/examples/graphics/hdr.example.mjs @@ -15,7 +15,8 @@ const assets = { 'texture', { url: `${rootPath}/static/assets/cubemaps/helipad-env-atlas.png` }, { type: pc.TEXTURETYPE_RGBP, mipmaps: false } - ) + ), + colorLut: new pc.Asset('colorLut', 'texture', { url: `${rootPath}/static/assets/cube-luts/lut-blue.png` }) }; const gfxOptions = { @@ -163,6 +164,11 @@ assetListLoader.load(() => { cameraFrame.vignette.outer = 1; cameraFrame.vignette.curvature = 0.5; cameraFrame.vignette.intensity = 0.5; + + // Apply Color LUT + cameraFrame.colorLUT.texture = assets.colorLut.resource; + cameraFrame.colorLUT.intensity = 1.0; + cameraFrame.update(); // apply UI changes @@ -178,12 +184,18 @@ assetListLoader.load(() => { cameraFrame.rendering.toneMapping = value; cameraFrame.update(); } + + if (path === 'data.colorLutIntensity') { + cameraFrame.colorLUT.intensity = value; + cameraFrame.update(); + } }); // set initial values data.set('data', { hdr: true, - sceneTonemapping: pc.TONEMAP_ACES + sceneTonemapping: pc.TONEMAP_ACES, + colorLutIntensity: 1.0 }); }); diff --git a/scripts/esm/camera-frame.mjs b/scripts/esm/camera-frame.mjs index c6c6d59c3b8..9584d66541f 100644 --- a/scripts/esm/camera-frame.mjs +++ b/scripts/esm/camera-frame.mjs @@ -1,5 +1,11 @@ +// Camera Frame v 1.1 + import { CameraFrame as EngineCameraFrame, Script, Color } from 'playcanvas'; +/** + * @import { Asset } from 'playcanvas'; + */ + /** @enum {number} */ const ToneMapping = { LINEAR: 0, // TONEMAP_LINEAR @@ -217,6 +223,24 @@ class Grading { tint = new Color(1, 1, 1, 1); } +/** @interface */ +class ColorLUT { + /** + * @attribute + * @type {Asset} + * @resource texture + */ + texture = null; + + /** + * @visibleif {texture} + * @range [0, 1] + * @precision 3 + * @step 0.001 + */ + intensity = 1; +} + /** @interface */ class Vignette { enabled = false; @@ -359,6 +383,12 @@ class CameraFrame extends Script { */ grading = new Grading(); + /** + * @attribute + * @type {ColorLUT} + */ + colorLUT = new ColorLUT(); + /** * @attribute * @type {Vignette} @@ -409,7 +439,7 @@ class CameraFrame extends Script { postUpdate(dt) { const cf = this.engineCameraFrame; - const { rendering, bloom, grading, vignette, fringing, taa, ssao, dof } = this; + const { rendering, bloom, grading, vignette, fringing, taa, ssao, dof, colorLUT } = this; const dstRendering = cf.rendering; dstRendering.renderFormats.length = 0; @@ -453,6 +483,15 @@ class CameraFrame extends Script { dstGrading.tint.copy(grading.tint); } + // colorLUT + const dstColorLUT = cf.colorLUT; + if (colorLUT.texture?.resource) { + dstColorLUT.texture = colorLUT.texture.resource; + dstColorLUT.intensity = colorLUT.intensity; + } else { + dstColorLUT.texture = null; + } + // vignette const dstVignette = cf.vignette; dstVignette.intensity = vignette.enabled ? vignette.intensity : 0; diff --git a/src/extras/render-passes/camera-frame.js b/src/extras/render-passes/camera-frame.js index 92cb2ea9843..1cda470d2ad 100644 --- a/src/extras/render-passes/camera-frame.js +++ b/src/extras/render-passes/camera-frame.js @@ -8,6 +8,7 @@ import { CameraFrameOptions, RenderPassCameraFrame } from './render-pass-camera- /** * @import { AppBase } from '../../framework/app-base.js' * @import { CameraComponent } from '../../framework/components/camera/component.js' + * @import { Texture } from '../../platform/graphics/texture.js' */ /** @@ -98,6 +99,14 @@ import { CameraFrameOptions, RenderPassCameraFrame } from './render-pass-camera- * @property {Color} tint - The tint color of the grading effect. Defaults to white. */ +/** + * @typedef {Object} ColorLUT + * Properties related to the color lookup table (LUT) effect, a postprocessing technique used to + * apply a color transformation to the image. + * @property {Texture|null} texture - The texture of the color LUT effect. Defaults to null. + * @property {number} intensity - The intensity of the color LUT effect. Defaults to 1. + */ + /** * @typedef {Object} Vignette * Properties related to the vignette effect, a postprocessing technique that darkens the image @@ -224,6 +233,16 @@ class CameraFrame { tint: new Color(1, 1, 1, 1) }; + /** + * Color LUT settings. + * + * @type {ColorLUT} + */ + colorLUT = { + texture: null, + intensity: 1 + }; + /** * Vignette settings. * @@ -437,6 +456,9 @@ class CameraFrame { composePass.gradingTint = grading.tint; } + composePass.colorLUT = this.colorLUT.texture; + composePass.colorLUTIntensity = this.colorLUT.intensity; + composePass.vignetteEnabled = vignette.intensity > 0; if (composePass.vignetteEnabled) { composePass.vignetteInner = vignette.inner; diff --git a/src/extras/render-passes/render-pass-compose.js b/src/extras/render-passes/render-pass-compose.js index 6ba03370be4..4d94593e567 100644 --- a/src/extras/render-passes/render-pass-compose.js +++ b/src/extras/render-passes/render-pass-compose.js @@ -8,6 +8,10 @@ import { ShaderUtils } from '../../scene/shader-lib/shader-utils.js'; import { composeChunksGLSL } from '../../scene/shader-lib/glsl/collections/compose-chunks-glsl.js'; import { composeChunksWGSL } from '../../scene/shader-lib/wgsl/collections/compose-chunks-wgsl.js'; +/** + * @import { Texture } from '../../platform/graphics/texture.js'; + */ + /** * Render pass implementation of the final post-processing composition. * @@ -63,6 +67,13 @@ class RenderPassCompose extends RenderPassShaderQuad { _gammaCorrection = GAMMA_SRGB; + /** + * @type {Texture|null} + */ + _colorLUT = null; + + colorLUTIntensity = 1; + _key = ''; _debug = null; @@ -88,6 +99,9 @@ class RenderPassCompose extends RenderPassShaderQuad { this.sceneTextureInvResId = scope.resolve('sceneTextureInvRes'); this.sceneTextureInvResValue = new Float32Array(2); this.sharpnessId = scope.resolve('sharpness'); + this.colorLUTId = scope.resolve('colorLUT'); + this.colorLUTParams = new Float32Array(4); + this.colorLUTParamsId = scope.resolve('colorLUTParams'); } set debug(value) { @@ -101,6 +115,17 @@ class RenderPassCompose extends RenderPassShaderQuad { return this._debug; } + set colorLUT(value) { + if (this._colorLUT !== value) { + this._colorLUT = value; + this._shaderDirty = true; + } + } + + get colorLUT() { + return this._colorLUT; + } + set bloomTexture(value) { if (this._bloomTexture !== value) { this._bloomTexture = value; @@ -236,6 +261,7 @@ class RenderPassCompose extends RenderPassShaderQuad { `-${this.blurTextureUpscale ? 'dofupscale' : ''}` + `-${this.ssaoTexture ? 'ssao' : 'nossao'}` + `-${this.gradingEnabled ? 'grading' : 'nograding'}` + + `-${this.colorLUT ? 'colorlut' : 'nocolorlut'}` + `-${this.vignetteEnabled ? 'vignette' : 'novignette'}` + `-${this.fringingEnabled ? 'fringing' : 'nofringing'}` + `-${this.taaEnabled ? 'taa' : 'notaa'}` + @@ -253,6 +279,7 @@ class RenderPassCompose extends RenderPassShaderQuad { if (this.blurTextureUpscale) defines.set('DOF_UPSCALE', true); if (this.ssaoTexture) defines.set('SSAO', true); if (this.gradingEnabled) defines.set('GRADING', true); + if (this.colorLUT) defines.set('COLOR_LUT', true); if (this.vignetteEnabled) defines.set('VIGNETTE', true); if (this.fringingEnabled) defines.set('FRINGING', true); if (this.taaEnabled) defines.set('TAA', true); @@ -299,6 +326,16 @@ class RenderPassCompose extends RenderPassShaderQuad { this.tintId.setValue([this.gradingTint.r, this.gradingTint.g, this.gradingTint.b]); } + const lutTexture = this._colorLUT; + if (lutTexture) { + this.colorLUTParams[0] = lutTexture.width; + this.colorLUTParams[1] = lutTexture.height; + this.colorLUTParams[2] = lutTexture.height - 1.0; + this.colorLUTParams[3] = this.colorLUTIntensity; + this.colorLUTParamsId.setValue(this.colorLUTParams); + this.colorLUTId.setValue(lutTexture); + } + if (this._vignetteEnabled) { this.vignetterParamsId.setValue([this.vignetteInner, this.vignetteOuter, this.vignetteCurvature, this.vignetteIntensity]); } diff --git a/src/scene/shader-lib/glsl/chunks/render-pass/frag/compose/compose-color-lut.js b/src/scene/shader-lib/glsl/chunks/render-pass/frag/compose/compose-color-lut.js new file mode 100644 index 00000000000..c0ee7640cd3 --- /dev/null +++ b/src/scene/shader-lib/glsl/chunks/render-pass/frag/compose/compose-color-lut.js @@ -0,0 +1,38 @@ +export default /* glsl */` + #ifdef COLOR_LUT + uniform sampler2D colorLUT; + uniform vec4 colorLUTParams; // width, height, maxColor, intensity + + vec3 applyColorLUT(vec3 color) { + vec3 c = clamp(color, 0.0, 1.0); + + float width = colorLUTParams.x; + float height = colorLUTParams.y; + float maxColor = colorLUTParams.z; + + // Calculate blue axis slice + float cell = c.b * maxColor; + float cell_l = floor(cell); + float cell_h = ceil(cell); + + // Half-texel offsets + float half_px_x = 0.5 / width; + float half_px_y = 0.5 / height; + + // Red and green offsets within a tile + float r_offset = half_px_x + c.r / height * (maxColor / height); + float g_offset = half_px_y + c.g * (maxColor / height); + + // texture coordinates for the two blue slices + vec2 uv_l = vec2(cell_l / height + r_offset, g_offset); + vec2 uv_h = vec2(cell_h / height + r_offset, g_offset); + + // Sample both and interpolate + vec3 color_l = texture2DLod(colorLUT, uv_l, 0.0).rgb; + vec3 color_h = texture2DLod(colorLUT, uv_h, 0.0).rgb; + + vec3 lutColor = mix(color_l, color_h, fract(cell)); + return mix(color, lutColor, colorLUTParams.w); + } + #endif +`; diff --git a/src/scene/shader-lib/glsl/chunks/render-pass/frag/compose/compose.js b/src/scene/shader-lib/glsl/chunks/render-pass/frag/compose/compose.js index aaf18824115..989cb1932a5 100644 --- a/src/scene/shader-lib/glsl/chunks/render-pass/frag/compose/compose.js +++ b/src/scene/shader-lib/glsl/chunks/render-pass/frag/compose/compose.js @@ -13,6 +13,7 @@ export default /* glsl */` #include "composeVignettePS" #include "composeFringingPS" #include "composeCasPS" + #include "composeColorLutPS" void main() { vec2 uv = uv0; @@ -60,6 +61,11 @@ export default /* glsl */` // Apply Tone Mapping result = toneMap(result); + // Apply Color LUT after tone mapping, in LDR space + #ifdef COLOR_LUT + result = applyColorLUT(result); + #endif + // Apply Vignette #ifdef VIGNETTE result = applyVignette(result, uv); diff --git a/src/scene/shader-lib/glsl/collections/compose-chunks-glsl.js b/src/scene/shader-lib/glsl/collections/compose-chunks-glsl.js index f1ba473b422..fbf2e2e071a 100644 --- a/src/scene/shader-lib/glsl/collections/compose-chunks-glsl.js +++ b/src/scene/shader-lib/glsl/collections/compose-chunks-glsl.js @@ -6,6 +6,7 @@ import composeGradingPS from '../chunks/render-pass/frag/compose/compose-grading import composeVignettePS from '../chunks/render-pass/frag/compose/compose-vignette.js'; import composeFringingPS from '../chunks/render-pass/frag/compose/compose-fringing.js'; import composeCasPS from '../chunks/render-pass/frag/compose/compose-cas.js'; +import composeColorLutPS from '../chunks/render-pass/frag/compose/compose-color-lut.js'; export const composeChunksGLSL = { composePS, @@ -15,5 +16,6 @@ export const composeChunksGLSL = { composeGradingPS, composeVignettePS, composeFringingPS, - composeCasPS + composeCasPS, + composeColorLutPS }; diff --git a/src/scene/shader-lib/wgsl/chunks/render-pass/frag/compose/compose-color-lut.js b/src/scene/shader-lib/wgsl/chunks/render-pass/frag/compose/compose-color-lut.js new file mode 100644 index 00000000000..1dea54844b5 --- /dev/null +++ b/src/scene/shader-lib/wgsl/chunks/render-pass/frag/compose/compose-color-lut.js @@ -0,0 +1,39 @@ +export default /* wgsl */` + #ifdef COLOR_LUT + var colorLUT: texture_2d; + var colorLUTSampler: sampler; + uniform colorLUTParams: vec4f; // width, height, maxColor, intensity + + fn applyColorLUT(color: vec3f) -> vec3f { + var c: vec3f = clamp(color, vec3f(0.0), vec3f(1.0)); + + let width: f32 = uniform.colorLUTParams.x; + let height: f32 = uniform.colorLUTParams.y; + let maxColor: f32 = uniform.colorLUTParams.z; + + // Calculate blue axis slice + let cell: f32 = c.b * maxColor; + let cell_l: f32 = floor(cell); + let cell_h: f32 = ceil(cell); + + // Half-texel offsets + let half_px_x: f32 = 0.5 / width; + let half_px_y: f32 = 0.5 / height; + + // Red and green offsets within a tile + let r_offset: f32 = half_px_x + c.r / height * (maxColor / height); + let g_offset: f32 = half_px_y + c.g * (maxColor / height); + + // texture coordinates for the two blue slices + let uv_l: vec2f = vec2f(cell_l / height + r_offset, g_offset); + let uv_h: vec2f = vec2f(cell_h / height + r_offset, g_offset); + + // Sample both and interpolate + let color_l: vec3f = textureSampleLevel(colorLUT, colorLUTSampler, uv_l, 0.0).rgb; + let color_h: vec3f = textureSampleLevel(colorLUT, colorLUTSampler, uv_h, 0.0).rgb; + + let lutColor: vec3f = mix(color_l, color_h, fract(cell)); + return mix(color, lutColor, uniform.colorLUTParams.w); + } + #endif +`; diff --git a/src/scene/shader-lib/wgsl/chunks/render-pass/frag/compose/compose.js b/src/scene/shader-lib/wgsl/chunks/render-pass/frag/compose/compose.js index a0ccc11c9b2..d238e5d329f 100644 --- a/src/scene/shader-lib/wgsl/chunks/render-pass/frag/compose/compose.js +++ b/src/scene/shader-lib/wgsl/chunks/render-pass/frag/compose/compose.js @@ -14,6 +14,7 @@ export default /* wgsl */` #include "composeVignettePS" #include "composeFringingPS" #include "composeCasPS" + #include "composeColorLutPS" @fragment fn fragmentMain(input: FragmentInput) -> FragmentOutput { @@ -61,6 +62,11 @@ export default /* wgsl */` // Apply Tone Mapping result = toneMap(result); + // Apply Color LUT after tone mapping, in LDR space + #ifdef COLOR_LUT + result = applyColorLUT(result); + #endif + // Apply Vignette #ifdef VIGNETTE result = applyVignette(result, uv); diff --git a/src/scene/shader-lib/wgsl/collections/compose-chunks-wgsl.js b/src/scene/shader-lib/wgsl/collections/compose-chunks-wgsl.js index 28fcd531dac..eea437535d5 100644 --- a/src/scene/shader-lib/wgsl/collections/compose-chunks-wgsl.js +++ b/src/scene/shader-lib/wgsl/collections/compose-chunks-wgsl.js @@ -6,6 +6,7 @@ import composeGradingPS from '../chunks/render-pass/frag/compose/compose-grading import composeVignettePS from '../chunks/render-pass/frag/compose/compose-vignette.js'; import composeFringingPS from '../chunks/render-pass/frag/compose/compose-fringing.js'; import composeCasPS from '../chunks/render-pass/frag/compose/compose-cas.js'; +import composeColorLutPS from '../chunks/render-pass/frag/compose/compose-color-lut.js'; export const composeChunksWGSL = { composePS, @@ -15,5 +16,6 @@ export const composeChunksWGSL = { composeGradingPS, composeVignettePS, composeFringingPS, - composeCasPS + composeCasPS, + composeColorLutPS };