diff --git a/indra/llrender/llshadermgr.cpp b/indra/llrender/llshadermgr.cpp index eb38126c10..669fe37361 100644 --- a/indra/llrender/llshadermgr.cpp +++ b/indra/llrender/llshadermgr.cpp @@ -1416,6 +1416,9 @@ void LLShaderMgr::initAttribsAndUniforms() mReservedUniforms.push_back("res_scale"); mReservedUniforms.push_back("dof_width"); mReservedUniforms.push_back("dof_height"); + mReservedUniforms.push_back("bokeh_shape"); + mReservedUniforms.push_back("bokeh_lens"); + mReservedUniforms.push_back("bokeh_intensity"); mReservedUniforms.push_back("depthMap"); mReservedUniforms.push_back("shadowMap0"); diff --git a/indra/llrender/llshadermgr.h b/indra/llrender/llshadermgr.h index 8e9a251851..5afb29cc26 100644 --- a/indra/llrender/llshadermgr.h +++ b/indra/llrender/llshadermgr.h @@ -220,6 +220,9 @@ class LLShaderMgr DOF_RES_SCALE, // "res_scale" DOF_WIDTH, // "dof_width" DOF_HEIGHT, // "dof_height" + DOF_BOKEH_SHAPE, // "bokeh_shape" (vec4: 2pi/N, pi/N, cos(pi/N), roundness) + DOF_BOKEH_LENS, // "bokeh_lens" (vec4: rotationRad, anisoX, anisoY, active) + DOF_BOKEH_INTENSITY, // "bokeh_intensity" (float: highlight-weight exponent) DEFERRED_DEPTH, // "depthMap" DEFERRED_SHADOW0, // "shadowMap0" diff --git a/indra/newview/CMakeLists.txt b/indra/newview/CMakeLists.txt index 42c23a6fc6..6146c68ef9 100644 --- a/indra/newview/CMakeLists.txt +++ b/indra/newview/CMakeLists.txt @@ -173,6 +173,7 @@ set(viewer_SOURCE_FILES alfloatersceneexplorer.cpp alfloatersceneexplorerfilters.cpp alfloatertransactionlog.cpp + aldofbokeh.cpp alsceneexplorerpredicate.cpp alfloaterwebprofile.cpp allegacynotificationwellwindow.cpp @@ -941,6 +942,7 @@ set(viewer_HEADER_FILES alfloatersceneexplorer.h alfloatersceneexplorerfilters.h alfloatertransactionlog.h + aldofbokeh.h alsceneexplorerpredicate.h alfloaterwebprofile.h allegacynotificationwellwindow.h @@ -2371,6 +2373,7 @@ if (BUILD_TESTING) # This creates a separate test project per file listed. SET(viewer_TEST_SOURCE_FILES + aldofbokeh.cpp alsceneexplorerpredicate.cpp llagentaccess.cpp lldateutil.cpp diff --git a/indra/newview/aldofbokeh.cpp b/indra/newview/aldofbokeh.cpp new file mode 100644 index 0000000000..19b210ed13 --- /dev/null +++ b/indra/newview/aldofbokeh.cpp @@ -0,0 +1,72 @@ +/** + * @file aldofbokeh.cpp + * @brief Pure CPU-side baking of depth-of-field bokeh (aperture / anamorphic) kernel parameters. + * + * Copyright (c) 2026, Alchemy Viewer Project + * + * The source code in this file is provided to you under the terms of the + * GNU Lesser General Public License, version 2.1, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A + * PARTICULAR PURPOSE. Terms of the LGPL can be found in doc/LGPL-licence.txt + * in this distribution, or online at http://www.gnu.org/licenses/lgpl-2.1.txt + * + */ + +#include "linden_common.h" + +#include "aldofbokeh.h" + +#include "llmath.h" + +namespace ALDoFBokeh +{ + Kernel bakeKernel(S32 blades, F32 roundness, F32 rotationDeg, F32 anamorphicRatio, F32 intensity) + { + // Clamp to documented ranges up front: a bad debug-setting value must + // never reach the gather shader. Fewer than 3 blades has no polygon. + const S32 clampedBlades = (blades < 3) ? 0 : llmin(blades, 12); + roundness = llclamp(roundness, 0.f, 1.f); + anamorphicRatio = llclamp(anamorphicRatio, 0.25f, 4.f); + intensity = llclamp(intensity, 0.f, 8.f); + + Kernel k; + + if (clampedBlades >= 3) + { + const F32 n = (F32)clampedBlades; + k.seg = 2.f * F_PI / n; + k.halfSeg = F_PI / n; + k.edge = cosf(k.halfSeg); // inscribed radius of the N-gon + } + else + { + // Neutral constants the shader reads as "circular". + k.seg = 0.f; + k.halfSeg = 0.f; + k.edge = 1.f; + } + + k.roundness = roundness; + k.rotationRad = rotationDeg * DEG_TO_RAD; + + // Area-preserving anamorphic squeeze: stretch one axis and shrink the + // other by the same factor so overall blur energy stays constant. + const F32 s = sqrtf(anamorphicRatio); + k.anisoX = 1.f / s; + k.anisoY = s; + + // Highlight-weight exponent for the gather. Independent of the offset reshape + // (it also intensifies a plain round bokeh); 1.0 reproduces the classic + // weighting exactly. + k.intensity = intensity; + + // Only mark the kernel active when it actually reshapes the OFFSET, so the + // default (no polygon, ratio 1.0) leaves the gather on its circular path. + // Intensity is deliberately excluded -- it is applied regardless. A small + // epsilon keeps ratios that round to ~1.0 (e.g. UI-slider noise) on the + // circular path, since they are visually indistinguishable from it. + k.active = (clampedBlades >= 3) || (fabsf(anamorphicRatio - 1.f) > 1e-3f); + + return k; + } +} diff --git a/indra/newview/aldofbokeh.h b/indra/newview/aldofbokeh.h new file mode 100644 index 0000000000..a0fefcff77 --- /dev/null +++ b/indra/newview/aldofbokeh.h @@ -0,0 +1,61 @@ +/** + * @file aldofbokeh.h + * @brief Pure CPU-side baking of depth-of-field bokeh (aperture / anamorphic) kernel parameters. + * + * Copyright (c) 2026, Alchemy Viewer Project + * + * The source code in this file is provided to you under the terms of the + * GNU Lesser General Public License, version 2.1, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A + * PARTICULAR PURPOSE. Terms of the LGPL can be found in doc/LGPL-licence.txt + * in this distribution, or online at http://www.gnu.org/licenses/lgpl-2.1.txt + * + */ + +#ifndef AL_DOFBOKEH_H +#define AL_DOFBOKEH_H + +#include "stdtypes.h" + +// Depth-of-field bokeh shaping. +// +// The DoF gather (postDeferredF.glsl) places its blur taps on a circle. To give +// the bokeh real lens character we reshape WHERE each tap lands -- into an +// aperture-blade polygon and/or an anamorphic oval -- without changing HOW MANY +// taps are taken, so the O(CoC^2) gather cost is unaffected. +// +// The trig the shader needs is constant across the whole frame, so we bake it +// once here on the CPU (mirroring the chromatic-aberration path in +// LLPipeline::colorCorrect) and hand the shader a ready-to-use form. +namespace ALDoFBokeh +{ + // Shader-ready bokeh kernel. The geometry packs into two vec4 uniforms + // consumed by postDeferredF.glsl, plus a scalar intensity uniform: + // bokeh_shape = (seg, halfSeg, edge, roundness) + // bokeh_lens = (rotationRad, anisoX, anisoY, active ? 1 : 0) + // bokeh_intensity = intensity + struct Kernel + { + bool active; // false => shader takes the plain circular path for the OFFSET + F32 seg; // 2*pi / blades (0 when no polygon) + F32 halfSeg; // pi / blades (0 when no polygon) + F32 edge; // cos(pi / blades) -- inscribed N-gon radius factor (1 when no polygon) + F32 roundness; // 0 = crisp polygon .. 1 = circle + F32 rotationRad; // aperture rotation, radians + F32 anisoX; // anamorphic per-axis scale, area preserving: anisoX * anisoY == 1 + F32 anisoY; + F32 intensity; // highlight-weight EXPONENT: 1 = classic, higher = punchier / more + // defined highlights, 0 = flat. Independent of `active` (it also + // intensifies a round bokeh). + }; + + // Bake the shader kernel from raw setting values. Inputs are clamped to their + // documented, sane ranges here so an out-of-range debug setting can never feed + // garbage into the gather: blades below 3 disable the polygon (circular) and + // blades above 12 are capped at 12; roundness clamps to [0, 1]; anamorphicRatio + // clamps to [0.25, 4] (1 = off); intensity clamps to [0, 8] (1 = off; the shader + // applies it as an exponent). + Kernel bakeKernel(S32 blades, F32 roundness, F32 rotationDeg, F32 anamorphicRatio, F32 intensity); +} + +#endif // AL_DOFBOKEH_H diff --git a/indra/newview/app_settings/settings_alchemy.xml b/indra/newview/app_settings/settings_alchemy.xml index f0251c620f..eb12b44c94 100644 --- a/indra/newview/app_settings/settings_alchemy.xml +++ b/indra/newview/app_settings/settings_alchemy.xml @@ -2761,6 +2761,61 @@ Value 0 + RenderDoFBokehBlades + + Comment + Depth of Field bokeh aperture blade count. Fewer than 3 gives a round bokeh (off/default); 5-9 emulates a partially-closed lens iris (pentagon..enneagon). Range 0 to 12. + Persist + 1 + Type + S32 + Value + 0 + + RenderDoFBokehRoundness + + Comment + How rounded the bokeh polygon edges are when RenderDoFBokehBlades is 3 or more. 0 is a crisp straight-edged polygon; 1 is a full circle. Range 0 to 1. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderDoFBokehRotation + + Comment + Rotation of the bokeh aperture polygon, in degrees. Only meaningful when RenderDoFBokehBlades is 3 or more. Range 0 to 360. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderDoFAnamorphicRatio + + Comment + Anamorphic bokeh squeeze. 1.0 is circular (off/default); greater than 1 stretches the bokeh into a vertical oval for an anamorphic-lens look. Area preserving. Range 0.25 to 4. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderDoFBokehIntensity + + Comment + Exponent controlling how sharply bright out-of-focus highlights dominate the bokeh. 1.0 is the default (classic look); higher values (try 2 to 4) make bright points form punchier, more defined bokeh discs, which makes the aperture shape and rotation easier to see; below 1 softens toward a flat, uniform blur (0 = fully flat). Range 0 to 8. + Persist + 1 + Type + F32 + Value + 1.0 + AllowNoCopyRezRestoreToWorld Comment diff --git a/indra/newview/app_settings/shaders/class1/deferred/postDeferredF.glsl b/indra/newview/app_settings/shaders/class1/deferred/postDeferredF.glsl index 7167c8e170..b3a2be39c1 100644 --- a/indra/newview/app_settings/shaders/class1/deferred/postDeferredF.glsl +++ b/indra/newview/app_settings/shaders/class1/deferred/postDeferredF.glsl @@ -34,6 +34,11 @@ uniform vec2 screen_res; uniform float max_cof; uniform float res_scale; +// Bokeh aperture shaping, CPU-baked in LLPipeline::renderDoF() via ALDoFBokeh::bakeKernel(). +uniform vec4 bokeh_shape; // (2pi/N, pi/N, cos(pi/N), roundness); N = aperture blade count +uniform vec4 bokeh_lens; // (rotation_radians, anamorphic_x, anamorphic_y, active flag) +uniform float bokeh_intensity; // exponent on the highlight weight (1 = classic, higher = more defined) + in vec2 vary_fragcoord; void dofSample(inout vec4 diff, inout float w, float min_sc, vec2 tc) @@ -44,10 +49,15 @@ void dofSample(inout vec4 diff, inout float w, float min_sc, vec2 tc) if (sc > min_sc) //sampled pixel is more "out of focus" than current sample radius { - float wg = 0.25; - - // de-weight dull areas to make highlights 'pop' - wg += s.r+s.g+s.b; + // de-weight dull areas to make highlights 'pop'. bokeh_intensity is an + // exponent, not a scale: a scale cancels in the normalized average below, + // so it would saturate; an exponent changes which samples dominate and + // keeps working across its whole range. 1.0 is the classic linear weight + // (bit-exact fast path). Base is clamped for overflow safety and floored + // so bokeh_intensity==0 ("fully flat", a documented value) cannot hit + // undefined pow(0,0) on a black tap -- NaN there smears across the disc. + float lum = s.r+s.g+s.b; + float wg = 0.25 + (bokeh_intensity == 1.0 ? lum : pow(clamp(lum, 1e-5, 1024.0), bokeh_intensity)); diff += wg*s; @@ -59,16 +69,47 @@ void dofSampleNear(inout vec4 diff, inout float w, float min_sc, vec2 tc) { vec4 s = texture(diffuseRect, tc); - float wg = 0.25; - - // de-weight dull areas to make highlights 'pop' - wg += s.r+s.g+s.b; + // de-weight dull areas to make highlights 'pop'. bokeh_intensity is an exponent + // (see dofSample): a scale would cancel in the normalized average and saturate, + // while an exponent keeps working across its range. 1.0 = classic linear weight. + float lum = s.r+s.g+s.b; + float wg = 0.25 + (bokeh_intensity == 1.0 ? lum : pow(clamp(lum, 1e-5, 1024.0), bokeh_intensity)); diff += wg*s; w += wg; } +// Aperture-shaped bokeh tap offset. Reshapes WHERE a tap lands (aperture polygon +// and/or anamorphic squeeze); the caller's tap COUNT is unchanged, so this does +// not affect the O(CoC^2) gather cost. When the kernel is inactive it returns the +// exact circular offset the gather used before, so the default look and cost are +// preserved bit-for-bit. +vec2 bokehOffset(float r, float a) +{ + if (bokeh_lens.w < 0.5) + return vec2(sin(a), cos(a)) * r; // fast path: unchanged circular bokeh + + // Build the aperture in its own (unrotated) frame: N-gon radius, then the + // anamorphic per-axis squeeze. + float shape = 1.0; + if (bokeh_shape.x > 0.0) // polygon active (blades >= 3) + { + // Fold the angle into one blade segment and pinch the ring onto the + // inscribed N-gon edge, then blend back toward a circle by roundness. + float s = mod(a, bokeh_shape.x) - bokeh_shape.y; + shape = mix(bokeh_shape.z / cos(s), 1.0, bokeh_shape.w); + } + vec2 local = vec2(sin(a), cos(a)) * (r * shape) * bokeh_lens.yz; + + // Rigidly rotate the whole aperture -- polygon AND anamorphic oval -- into + // screen space. bokeh_lens.x is a uniform, so this cos/sin is loop-invariant + // and gets hoisted out of the gather loop by the compiler. + float cs = cos(bokeh_lens.x); + float sn = sin(bokeh_lens.x); + return vec2(local.x * cs - local.y * sn, local.x * sn + local.y * cs); +} + vec3 clampHDRRange(vec3 color); void main() @@ -94,10 +135,9 @@ void main() for (int i=0; i bokeh_blades(gSavedSettings, "RenderDoFBokehBlades", 0); + static LLCachedControl bokeh_roundness(gSavedSettings, "RenderDoFBokehRoundness", 0.f); + static LLCachedControl bokeh_rotation(gSavedSettings, "RenderDoFBokehRotation", 0.f); + static LLCachedControl bokeh_anamorphic(gSavedSettings, "RenderDoFAnamorphicRatio", 1.f); + static LLCachedControl bokeh_intensity(gSavedSettings, "RenderDoFBokehIntensity", 1.f); + ALDoFBokeh::Kernel bokeh = ALDoFBokeh::bakeKernel(bokeh_blades, bokeh_roundness, bokeh_rotation, bokeh_anamorphic, bokeh_intensity); + post_program.uniform4f(LLShaderMgr::DOF_BOKEH_SHAPE, bokeh.seg, bokeh.halfSeg, bokeh.edge, bokeh.roundness); + post_program.uniform4f(LLShaderMgr::DOF_BOKEH_LENS, bokeh.rotationRad, bokeh.anisoX, bokeh.anisoY, bokeh.active ? 1.f : 0.f); + post_program.uniform1f(LLShaderMgr::DOF_BOKEH_INTENSITY, bokeh.intensity); + mScreenTriangleVB->setBuffer(); mScreenTriangleVB->drawArrays(LLRender::TRIANGLES, 0, 3); diff --git a/indra/newview/tests/aldofbokeh_test.cpp b/indra/newview/tests/aldofbokeh_test.cpp new file mode 100644 index 0000000000..8686089d82 --- /dev/null +++ b/indra/newview/tests/aldofbokeh_test.cpp @@ -0,0 +1,135 @@ +/** + * @file aldofbokeh_test.cpp + * @brief Unit tests for the pure DoF bokeh-kernel baking (ALDoFBokeh::bakeKernel) + * + * Copyright (c) 2026, Alchemy Viewer Project + * + * The source code in this file is provided to you under the terms of the + * GNU Lesser General Public License, version 2.1, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A + * PARTICULAR PURPOSE. Terms of the LGPL can be found in doc/LGPL-licence.txt + * in this distribution, or online at http://www.gnu.org/licenses/lgpl-2.1.txt + * + */ + +#include "linden_common.h" + +#include "../test/lltut.h" + +#include "../aldofbokeh.h" + +#include "llmath.h" + +namespace tut +{ + struct dofbokeh_data + { + }; + + typedef test_group dofbokeh_group; + typedef dofbokeh_group::object dofbokeh_object; + tut::dofbokeh_group dofbokehinst("ALDoFBokeh"); + + using namespace ALDoFBokeh; + + // The default/neutral setting values must produce an INACTIVE kernel whose + // packed constants are the identity the shader's circular fast-path relies on, + // and a neutral (1.0) intensity. + template<> template<> + void dofbokeh_object::test<1>() + { + Kernel k = bakeKernel(0, 0.f, 0.f, 1.f, 1.f); + ensure("neutral input is inactive", !k.active); + ensure_equals("no polygon segment", k.seg, 0.f); + ensure_equals("edge factor is unity", k.edge, 1.f); + ensure_distance("aniso x unity", k.anisoX, 1.f, 1e-6f); + ensure_distance("aniso y unity", k.anisoY, 1.f, 1e-6f); + ensure_distance("intensity unity", k.intensity, 1.f, 1e-6f); + } + + // Any blade count below 3 is treated as "no polygon" (a 2-gon is degenerate). + template<> template<> + void dofbokeh_object::test<2>() + { + for (S32 n = 0; n < 3; ++n) + { + Kernel k = bakeKernel(n, 0.5f, 0.f, 1.f, 1.f); + ensure("blades<3 is inactive", !k.active); + ensure_equals("blades<3 has no polygon segment", k.seg, 0.f); + } + } + + // A hexagonal aperture bakes exactly the documented N-gon constants. + template<> template<> + void dofbokeh_object::test<3>() + { + Kernel k = bakeKernel(6, 0.f, 0.f, 1.f, 1.f); + ensure("hexagon is active", k.active); + ensure_distance("seg = 2pi/6", k.seg, F_PI / 3.f, 1e-5f); + ensure_distance("halfSeg = pi/6", k.halfSeg, F_PI / 6.f, 1e-5f); + ensure_distance("edge = cos(pi/6)", k.edge, cosf(F_PI / 6.f), 1e-5f); + } + + // Rotation is converted from degrees to radians. + template<> template<> + void dofbokeh_object::test<4>() + { + Kernel k = bakeKernel(6, 0.f, 90.f, 1.f, 1.f); + ensure_distance("90 deg -> pi/2 rad", k.rotationRad, F_PI * 0.5f, 1e-5f); + } + + // Anamorphic squeeze activates on its own and is area preserving. + template<> template<> + void dofbokeh_object::test<5>() + { + Kernel k = bakeKernel(0, 0.f, 0.f, 1.6f, 1.f); + ensure("anamorphic alone is active", k.active); + ensure_distance("area is preserved", k.anisoX * k.anisoY, 1.f, 1e-5f); + ensure("long axis grows", k.anisoY > 1.f); + ensure("short axis shrinks", k.anisoX < 1.f); + } + + // Out-of-range geometry inputs are clamped, never passed through. + template<> template<> + void dofbokeh_object::test<6>() + { + // blades 999 -> clamped to 12 (still a valid, active polygon); + // roundness 5 -> clamped to 1; ratio 99 -> clamped to 4 (sqrt = 2). + Kernel hi = bakeKernel(999, 5.f, 0.f, 99.f, 1.f); + ensure("clamped blades still active", hi.active); + ensure("roundness clamped to <= 1", hi.roundness <= 1.f); + ensure("edge stays a valid cosine", hi.edge > 0.f && hi.edge < 1.f); + ensure_distance("anamorphic ratio clamped high", hi.anisoY, 2.f, 1e-4f); + + // roundness -1 -> clamped to 0; ratio 0.01 -> clamped to 0.25 (sqrt = 0.5). + Kernel lo = bakeKernel(6, -1.f, 0.f, 0.01f, 1.f); + ensure("roundness clamped to >= 0", lo.roundness >= 0.f); + ensure_distance("anamorphic ratio clamped low", lo.anisoX, 2.f, 1e-4f); + } + + // Intensity is an independent, clamped passthrough: default 1.0 preserved, + // out-of-range clamped, and it does not by itself activate the offset reshape. + template<> template<> + void dofbokeh_object::test<7>() + { + ensure_distance("default intensity passes through", bakeKernel(0, 0.f, 0.f, 1.f, 1.f).intensity, 1.f, 1e-6f); + ensure_distance("a mid intensity passes through", bakeKernel(0, 0.f, 0.f, 1.f, 4.f).intensity, 4.f, 1e-6f); + ensure_distance("negative intensity clamps to 0", bakeKernel(0, 0.f, 0.f, 1.f, -5.f).intensity, 0.f, 1e-6f); + ensure_distance("huge intensity clamps to 8", bakeKernel(0, 0.f, 0.f, 1.f, 999.f).intensity, 8.f, 1e-6f); + + // Intensity alone must not flip the offset reshape on. + ensure("intensity alone does not activate offset", !bakeKernel(0, 0.f, 0.f, 1.f, 8.f).active); + } + + // blades == 3 is the exact activation boundary (the most off-by-one-prone value): + // it must be active and bake the triangle N-gon constants. + template<> template<> + void dofbokeh_object::test<8>() + { + Kernel k = bakeKernel(3, 0.f, 0.f, 1.f, 1.f); + ensure("blades==3 is active", k.active); + ensure_distance("seg = 2pi/3", k.seg, 2.f * F_PI / 3.f, 1e-5f); + ensure_distance("halfSeg = pi/3", k.halfSeg, F_PI / 3.f, 1e-5f); + ensure_distance("edge = cos(pi/3)", k.edge, cosf(F_PI / 3.f), 1e-5f); + } +}