diff --git a/manual/assets/js/src/demos/lut.ts b/manual/assets/js/src/demos/lut.ts index d25157dc6..cc1ab6bab 100644 --- a/manual/assets/js/src/demos/lut.ts +++ b/manual/assets/js/src/demos/lut.ts @@ -16,9 +16,14 @@ import { import { ClearPass, + EffectPass, GeometryPass, LookupTexture, - RenderPipeline + LUT3DEffect, + MixBlendFunction, + RawImageData, + RenderPipeline, + ToneMappingEffect } from "postprocessing"; import { LUT3dlLoader } from "three/examples/jsm/loaders/LUT3dlLoader.js"; @@ -48,9 +53,9 @@ const luts = new Map([ ["cube/django-25", "cube/django-25.cube"] ]); -function load(): Promise> { +function load(): Promise> { - const assets = new Map(); + const assets = new Map(); const loadingManager = new LoadingManager(); const textureLoader = new TextureLoader(loadingManager); const lut3dlLoader = new LUT3dlLoader(loadingManager); @@ -68,7 +73,7 @@ function load(): Promise> { lutNeutral8.name = "neutral-8"; assets.set(lutNeutral8.name, lutNeutral8); - return new Promise>((resolve, reject) => { + return new Promise>((resolve, reject) => { loadingManager.onLoad = () => resolve(assets); loadingManager.onError = (url) => reject(new Error(`Failed to load ${url}`)); @@ -117,7 +122,7 @@ function load(): Promise> { t.wrapS = ClampToEdgeWrapping; t.wrapT = ClampToEdgeWrapping; t.flipY = false; - assets.set(entry[0], t); + assets.set(entry[0], LookupTexture.from(t)); }); @@ -180,29 +185,24 @@ window.addEventListener("load", () => void load().then((assets) => { // Post Processing + const lut = assets.get("png/filmic1") as LookupTexture; + const effect = new LUT3DEffect(lut); + effect.blendMode.blendFunction = new MixBlendFunction(); + const pipeline = new RenderPipeline(renderer); pipeline.add( new ClearPass(), new GeometryPass(scene, camera, { frameBufferType: HalfFloatType - }) + }), + new EffectPass(new ToneMappingEffect(), effect) ); - /* - const lut = LookupTexture.from(assets.get("png/filmic1") as Texture); - const effect = renderer.capabilities.isWebGL2 ? new LUT3DEffect(lut) : - new LUT3DEffect(lut.convertToUint8().toDataTexture()); - - effect.blendMode.blendFunction = new MixBlendFunction(); - pipeline.addPass(new EffectPass(effect, new ToneMappingEffect())); - */ - // Settings const pane = new Pane({ container: container.querySelector(".tp") as HTMLElement }); const fpsGraph = Utils.createFPSGraph(pane); - /* const params = { "lut": effect.lut.name, "3D texture": true, @@ -215,8 +215,7 @@ window.addEventListener("load", () => void load().then((assets) => { function updateLUTPreview() { - const lut = LookupTexture.from(effect.lut); - const { image } = lut.convertToUint8().toDataTexture(); + const image = effect.lut.clone().convertToUint8().toDataTexture().image as ImageData; RawImageData.from(image).toCanvas().toBlob((blob) => { if(blob !== null) { @@ -235,7 +234,7 @@ window.addEventListener("load", () => void load().then((assets) => { function changeLUT(): void { - const original = assets.get(params.lut) as Texture; + const original = assets.get(params.lut) as LookupTexture; const size = Math.min(original.image.width, original.image.height); const scaleUp = params["scale up"] && (params["target size"] > size); @@ -243,14 +242,13 @@ window.addEventListener("load", () => void load().then((assets) => { if(scaleUp) { - const lut = (original instanceof LookupTexture) ? original : LookupTexture.from(original); console.time("Tetrahedral Upscaling"); - promise = lut.scaleUp(params["target size"], false); + promise = original.scaleUp(params["target size"], false); document.body.classList.add("progress"); } else { - promise = Promise.resolve(LookupTexture.from(original)); + promise = Promise.resolve(original.clone()); } @@ -266,23 +264,14 @@ window.addEventListener("load", () => void load().then((assets) => { effect.lut.dispose(); params["base size"] = size; - if(renderer.capabilities.isWebGL2) { - - if(renderer.getContext().getExtension("OES_texture_float_linear") === null) { - - console.log("Linear float filtering not supported, converting to Uint8"); - lut.convertToUint8(); + if(renderer.getContext().getExtension("OES_texture_float_linear") === null) { - } - - effect.lut = (params["3D texture"] ? lut : lut.toDataTexture()); - - } else { - - effect.lut = lut.convertToUint8().toDataTexture(); + console.log("Linear float filtering not supported, converting to Uint8"); + lut.convertToUint8(); } + effect.lut = lut; updateLUTPreview(); }).catch((error) => console.error(error)); @@ -290,21 +279,13 @@ window.addEventListener("load", () => void load().then((assets) => { } const folder = pane.addFolder({ title: "Settings" }); - folder.addBinding(params, "lut", { options: toRecord([...luts.keys()]) }).on("change", changeLUT); - - if(renderer.capabilities.isWebGL2) { - - folder.addBinding(params, "3D texture").on("change", changeLUT); - folder.addBinding(effect, "tetrahedralInterpolation"); - - } - + folder.addBinding(params, "lut", { options: Utils.arrayToRecord([...luts.keys()]) }).on("change", changeLUT); + folder.addBinding(effect, "tetrahedralInterpolation"); folder.addBinding(params, "base size", { readonly: true, format: (v) => v.toFixed(0) }); folder.addBinding(params, "scale up").on("change", changeLUT); - folder.addBinding(params, "target size", { options: toRecord([32, 48, 64, 128]) }).on("change", changeLUT); + folder.addBinding(params, "target size", { options: Utils.arrayToRecord([32, 48, 64, 128]) }).on("change", changeLUT); Utils.addBlendModeBindings(folder, effect.blendMode); - */ // Resize Handler diff --git a/manual/content/demos/color-grading/lut.en.md b/manual/content/demos/color-grading/lut.en.md index 8de440041..d1bee96f4 100644 --- a/manual/content/demos/color-grading/lut.en.md +++ b/manual/content/demos/color-grading/lut.en.md @@ -2,7 +2,7 @@ layout: single collection: sections title: LUT -draft: true +draft: false menu: demos: parent: color-grading diff --git a/src/effects/LUT3DEffect.ts b/src/effects/LUT3DEffect.ts new file mode 100644 index 000000000..0d029db9c --- /dev/null +++ b/src/effects/LUT3DEffect.ts @@ -0,0 +1,277 @@ +import { + ColorSpace, + FloatType, + HalfFloatType, + LinearFilter, + NearestFilter, + SRGBColorSpace, + Uniform, + Vector3 +} from "three"; + +import { LookupTexture } from "../textures/lut/LookupTexture.js"; +import { LUTDomainBounds } from "../textures/lut/LUTDomainBounds.js"; +import { Effect } from "./Effect.js"; + +import fragmentShader from "./shaders/lut-3d.frag"; + +/** + * LUT3DEffect options. + * + * @category Effects + */ + +export interface LUT3DEffectOptions { + + /** + * Enables or disables tetrahedral interpolation. + * + * @defaultValue false + */ + + tetrahedralInterpolation?: boolean; + + /** + * The input color space. + * + * @defaultValue SRGBColorSpace + */ + + inputColorSpace?: ColorSpace; + +} + +/** + * A LUT effect. + * + * The tetrahedral interpolation algorithm was inspired by an implementation from OpenColorIO which is licensed under + * the BSD 3-Clause License. + * + * The manual trilinear interpolation algorithm is based on an implementation by Garret Johnson which is licensed under + * the MIT License. + * + * @see https://developer.nvidia.com/gpugems/gpugems2/part-iii-high-quality-rendering/chapter-24-using-lookup-tables-accelerate-color + * @see https://www.nvidia.com/content/GTC/posters/2010/V01-Real-Time-Color-Space-Conversion-for-High-Resolution-Video.pdf + * @see https://github.com/AcademySoftwareFoundation/OpenColorIO/blob/master/src/OpenColorIO/ops/lut3d/ + * @see https://github.com/gkjohnson/threejs-sandbox/tree/master/3d-lut + * @category Effects + */ + +export class LUT3DEffect extends Effect { + + /** + * Constructs a new LUT effect. + * + * @param lut - The LUT. + * @param options - The options. + */ + + constructor(lut: LookupTexture, { + tetrahedralInterpolation = false, + inputColorSpace = SRGBColorSpace + }: LUT3DEffectOptions = {}) { + + super("LUT3DEffect"); + + this.fragmentShader = fragmentShader; + + const uniforms = this.input.uniforms; + uniforms.set("lut", new Uniform(null)); + uniforms.set("scale", new Uniform(new Vector3())); + uniforms.set("offset", new Uniform(new Vector3())); + uniforms.set("domainMin", new Uniform(null)); + uniforms.set("domainMax", new Uniform(null)); + + this.tetrahedralInterpolation = tetrahedralInterpolation; + this.inputColorSpace = inputColorSpace; + this.lut = lut; + + } + + /** + * Indicates whether the LUT uses high precision. + */ + + private get lutPrecisionHigh(): boolean { + + return this.input.defines.has("LUT_PRECISION_HIGH"); + + } + + private set lutPrecisionHigh(value: boolean) { + + if(this.lutPrecisionHigh !== value) { + + if(value) { + + this.input.defines.set("LUT_PRECISION_HIGH", true); + + } else { + + this.input.defines.delete("LUT_PRECISION_HIGH"); + + } + + this.setChanged(); + + } + + } + + /** + * The LUT. + */ + + get lut(): LookupTexture { + + return this.input.uniforms.get("lut")!.value as LookupTexture; + + } + + set lut(value: LookupTexture) { + + const { defines, uniforms } = this.input; + uniforms.get("lut")!.value = value; + + const image = value.image; + defines.set("LUT_SIZE", Math.min(image.width, image.height).toFixed(16)); + defines.set("LUT_TEXEL_WIDTH", (1.0 / image.width).toFixed(16)); + defines.set("LUT_TEXEL_HEIGHT", (1.0 / image.height).toFixed(16)); + + this.lutPrecisionHigh = (value.type === FloatType || value.type === HalfFloatType); + + const domainData = value.userData as LUTDomainBounds; + const min = domainData.domainMin; + const max = domainData.domainMax; + + uniforms.get("domainMin")!.value = min.clone(); + uniforms.get("domainMax")!.value = max.clone(); + + if(min.x !== 0 || min.y !== 0 || min.z !== 0 || max.x !== 1 || max.y !== 1 || max.z !== 1) { + + defines.set("CUSTOM_INPUT_DOMAIN", true); + + } else { + + defines.delete("CUSTOM_INPUT_DOMAIN"); + + } + + this.configureTetrahedralInterpolation(); + this.updateScaleOffset(); + this.setChanged(); + + } + + /** + * Updates the scale and offset for the LUT sampling coordinates. + */ + + private updateScaleOffset(): void { + + const lut = this.lut; + + if(lut === null) { + + return; + + } + + const size = Math.min(lut.image.width, lut.image.height); + const scale = this.input.uniforms.get("scale")!.value as Vector3; + const offset = this.input.uniforms.get("offset")!.value as Vector3; + + const domainBounds = lut.userData as LUTDomainBounds; + + if(this.tetrahedralInterpolation) { + + if(this.input.defines.has("CUSTOM_INPUT_DOMAIN")) { + + const domainScale = domainBounds.domainMax.clone().sub(domainBounds.domainMin); + scale.setScalar(size - 1).divide(domainScale); + offset.copy(domainBounds.domainMin).negate().multiply(scale); + + } else { + + scale.setScalar(size - 1); + offset.setScalar(0); + + } + + } else if(this.input.defines.has("CUSTOM_INPUT_DOMAIN")) { + + const domainScale = domainBounds.domainMax.clone().sub(domainBounds.domainMin).multiplyScalar(size); + scale.setScalar(size - 1).divide(domainScale); + offset.copy(domainBounds.domainMin).negate().multiply(scale).addScalar(1.0 / (2.0 * size)); + + } else { + + scale.setScalar((size - 1) / size); + offset.setScalar(1.0 / (2.0 * size)); + + } + + } + + /** + * Configures parameters for tetrahedral interpolation. + */ + + private configureTetrahedralInterpolation(): void { + + const lut = this.lut; + + if(lut === null) { + + return; + + } + + lut.minFilter = LinearFilter; + lut.magFilter = LinearFilter; + + if(this.tetrahedralInterpolation) { + + // Interpolate manually. + lut.minFilter = NearestFilter; + lut.magFilter = NearestFilter; + + } + + lut.needsUpdate = true; + + } + + /** + * Indicates whether tetrahedral interpolation is enabled. + * + * Tetrahedral interpolation produces highly accurate results but is slower than hardware interpolation. + * + * @defaultValue false + */ + + get tetrahedralInterpolation(): boolean { + + return this.input.defines.has("TETRAHEDRAL_INTERPOLATION"); + + } + + set tetrahedralInterpolation(value: boolean) { + + if(value) { + + this.input.defines.set("TETRAHEDRAL_INTERPOLATION", true); + + } else { + + this.input.defines.delete("TETRAHEDRAL_INTERPOLATION"); + + } + + this.configureTetrahedralInterpolation(); + this.updateScaleOffset(); + this.setChanged(); + + } + +} diff --git a/src/effects/index.ts b/src/effects/index.ts index 90e1cb8c1..3c66bd8e7 100644 --- a/src/effects/index.ts +++ b/src/effects/index.ts @@ -7,6 +7,7 @@ export * from "./FXAAEffect.js"; export * from "./HalftoneEffect.js"; export * from "./LensDistortionEffect.js"; export * from "./LUT1DEffect.js"; +export * from "./LUT3DEffect.js"; export * from "./ScanlineEffect.js"; export * from "./SMAAEffect.js"; export * from "./TextureEffect.js"; diff --git a/src/effects/shaders/lut-3d.frag b/src/effects/shaders/lut-3d.frag new file mode 100644 index 000000000..9e51ca911 --- /dev/null +++ b/src/effects/shaders/lut-3d.frag @@ -0,0 +1,162 @@ +uniform vec3 scale; +uniform vec3 offset; + +#ifdef CUSTOM_INPUT_DOMAIN + + uniform vec3 domainMin; + uniform vec3 domainMax; + +#endif + +#ifdef LUT_PRECISION_HIGH + + #ifdef GL_FRAGMENT_PRECISION_HIGH + + uniform highp sampler3D lut; + + #else + + uniform mediump sampler3D lut; + + #endif + +#else + + uniform lowp sampler3D lut; + +#endif + +vec4 applyLUT(in vec3 rgb) { + + rgb = scale * rgb + offset; + + #ifdef TETRAHEDRAL_INTERPOLATION + + // Strategy: Fetch the corners (v1, v2, v3, v4) of the tetrahedron that corresponds to the input coordinates. + // Calculate the barycentric weights and interpolate the nearest color samples. + + vec3 p = floor(rgb); + vec3 f = rgb - p; + + vec3 v1 = (p + 0.5) * LUT_TEXEL_WIDTH; + vec3 v4 = (p + 1.5) * LUT_TEXEL_WIDTH; + vec3 v2, v3; // Must be identified. + vec3 frac; + + if(f.r >= f.g) { + + if(f.g > f.b) { + + // T4: R >= G > B + frac = f.rgb; + v2 = vec3(v4.x, v1.y, v1.z); + v3 = vec3(v4.x, v4.y, v1.z); + + } else if(f.r >= f.b) { + + // T6: R >= B >= G + frac = f.rbg; + v2 = vec3(v4.x, v1.y, v1.z); + v3 = vec3(v4.x, v1.y, v4.z); + + } else { + + // T2: B > R >= G + frac = f.brg; + v2 = vec3(v1.x, v1.y, v4.z); + v3 = vec3(v4.x, v1.y, v4.z); + + } + + } else { + + if(f.b > f.g) { + + // T3: B > G > R + frac = f.bgr; + v2 = vec3(v1.x, v1.y, v4.z); + v3 = vec3(v1.x, v4.y, v4.z); + + } else if(f.r >= f.b) { + + // T5: G > R >= B + frac = f.grb; + v2 = vec3(v1.x, v4.y, v1.z); + v3 = vec3(v4.x, v4.y, v1.z); + + } else { + + // T1: G >= B > R + frac = f.gbr; + v2 = vec3(v1.x, v4.y, v1.z); + v3 = vec3(v1.x, v4.y, v4.z); + + } + + } + + // Interpolate manually to avoid 8-bit quantization of fractions. + vec4 n1 = texture(lut, v1); + vec4 n2 = texture(lut, v2); + vec4 n3 = texture(lut, v3); + vec4 n4 = texture(lut, v4); + + vec4 weights = vec4( + 1.0 - frac.x, + frac.x - frac.y, + frac.y - frac.z, + frac.z + ); + + // weights.x * n1 + weights.y * n2 + weights.z * n3 + weights.w * n4 + vec4 result = weights * mat4( + vec4(n1.r, n2.r, n3.r, n4.r), + vec4(n1.g, n2.g, n3.g, n4.g), + vec4(n1.b, n2.b, n3.b, n4.b), + vec4(1.0) + ); + + return vec4(result.rgb, 1.0); + + #else + + // Built-in trilinear interpolation. Note that the fractional components are quantized to 8 bits on common + // hardware which introduces significant error with small grid sizes. + return texture(lut, rgb); + + #endif + +} + +vec4 mainImage(const in vec4 inputColor, const in vec2 uv, const in GData gData) { + + vec3 c = inputColor.rgb; + + #ifdef CUSTOM_INPUT_DOMAIN + + if(c.r >= domainMin.r && c.g >= domainMin.g && c.b >= domainMin.b && + c.r <= domainMax.r && c.g <= domainMax.g && c.b <= domainMax.b) { + + c = applyLUT(c).rgb; + + } else { + + c = inputColor.rgb; + + } + + #else + + #ifdef TETRAHEDRAL_INTERPOLATION + + c = clamp(c, 0.0, 1.0); + + #endif + + c = applyLUT(c).rgb; + + #endif + + return vec4(c, inputColor.a); + +} diff --git a/test/effects/LUT3DEffect.js b/test/effects/LUT3DEffect.js new file mode 100644 index 000000000..3962dd977 --- /dev/null +++ b/test/effects/LUT3DEffect.js @@ -0,0 +1,11 @@ +import test from "ava"; +import { LookupTexture, LUT3DEffect } from "postprocessing"; + +test("can be created and destroyed", t => { + + const object = new LUT3DEffect(new LookupTexture(null, 1, 1)); + object.dispose(); + + t.pass(); + +});