diff --git a/doc/ARCHITECTURE.md b/doc/ARCHITECTURE.md index cb55884c32..2cf18722ed 100644 --- a/doc/ARCHITECTURE.md +++ b/doc/ARCHITECTURE.md @@ -88,8 +88,10 @@ The deferred rendering pipeline is orchestrated by `LLPipeline` (`indra/newview/ 2. **Shadows** (`renderGeomShadow()`) — 4 sun shadow cascades + 2 spot light shadow maps 3. **Deferred Lighting** (`renderDeferredLighting()`) — Sun, point lights (up to 16 batched), spot lights, with reflection probe influence 4. **Post-Deferred** (`renderGeomPostDeferred()`) — Alpha, water, atmospheric haze -5. **Post-Processing** — Luminance/exposure, bloom, DoF, tonemapping, color grading, AA, screen effects -6. **Finalize** (`renderFinalize()`) — Final blit with vignette, film grain, dithering, chromatic aberration +5. **Post-Processing** — Luminance/exposure, depth of field, bloom, then one pass doing tonemapping, color grading and the bloom composite together, then AA and CAS +6. **Finalize** (`renderFinalize()`) — Final blit with lens distortion, vignette, film grain, dithering, CVD compensation + +Depth of field runs **before** bloom and before the tonemapper, on linear HDR. Gathering over display-space values gathers over already-compressed highlights, and bloom-after-defocus is the optical order, so a defocused highlight blooms as a soft disc rather than a sharp core on a blurred background. **GBuffer layout** (MRT attachments on `deferredScreen`): - frag_data[0]: Base color (GL_RGBA) @@ -101,18 +103,22 @@ The deferred rendering pipeline is orchestrated by `LLPipeline` (`indra/newview/ - `mMainRT` — Full resolution for main scene - `mAuxillaryRT` — 512×512 for reflection probes and dynamic texture bakes - `mHeroProbeRT` — High-res hero probe rendering -- Additional targets: `mSceneMap` (SSR input), `mLuminanceMap`/`mExposureMap` (auto-exposure), `mPostPingMap`/`mPostPongMap` (post-process ping-pong), `mFXAAMap`, `mSMAABlendBuffer`, `mGlow[3]` (bloom pyramid), `mWaterDis` (refraction), `mSpotShadow[2]`, `mPbrBrdfLut` +- Additional targets: `mSceneMap` (SSR input), `mLuminanceMap`/`mExposureMap` (auto-exposure), `postPingMap`/`postPongMap` (post-process ping-pong), `mFXAAMap`, `mSMAABlendBuffer`, `bloomMip[BLOOM_MAX_MIPS]` (HDR bloom pyramid, up to 7 levels, live count in `bloomMipCount`), `dofSharp`/`dofBlur` (depth of field, allocated only when `RenderDepthOfField` is on), `crossFilter[3]` (cross-screen filter scratch and accumulator, allocated lazily on the first frame the effect is enabled and released when it is switched off — `crossFilterHeight` guards the state), `mWaterDis` (refraction), `mSpotShadow[2]`, `mPbrBrdfLut` +- `mGlow[3]` is the **legacy non-HDR glow chain**, not the bloom pyramid, and lives outside the pack. So does `mLensDirtMap`, the generated lens dirt plate — an `LLRenderTarget` allocated the first frame the effect is enabled and released when it is switched off. **Post-processing chain:** - **Auto-exposure:** Progressive histogram (`gLuminanceProgram`, `gExposureProgram`) with history fade -- **Bloom:** Bright-area extraction → 3-level glow pyramid (`mGlow[3]`) with warmth correction -- **Depth of Field:** Circle-of-confusion via `gDeferredCoFProgram`, combine via `gDeferredDoFCombineProgram`. Settings: `CameraFNumber`, `CameraFocalLength`, `CameraMaxCoF` +- **Bloom:** Bright-area extraction → downsample/upsample pyramid over `bloomMip[]` with warmth correction and optional halation carried in alpha. The additive composite is **not** a pass of its own: it is folded into `colorCorrect`'s `BLOOM_COMPOSITE` permutation. `compositeBloomHDR` is a standalone equivalent that nothing currently calls +- **Cross-screen (star) filter:** `gCrossFilterProgram` streaks every thresholded highlight, seeded from `bloomMip[0]` and accumulated through `crossFilter[0..2]`. One strictly one-sided three-pass chain per arm, at strides that tile the reachable offsets exactly once — the tiling is load-bearing and the shader explains why. Composited in `colorCorrect` alongside the pyramid, so it inherits bloom strength +- **Lens dirt:** `gLensDirtGenProgram` draws the grime plate into `mLensDirtMap` — dust motes, wipe smudges, stray fibres, fine grit and optional scratches, built from hashes and distance fields rather than loaded from an image. Not a per-frame pass: it runs when a generation parameter moves or the window resizes, guarded by the cached parameter set in `mLensDirtParams`, which is also what makes a failed allocation stop retrying. Generated at the frame's own resolution, so nothing has to fit a square plate to a wide window. A rebuild is held off while `mLensDirtSliderHeld` is raised — the Lightbox raises it on a generation slider's mouse-down and lowers it on mouse-up, so a drag costs one plate on release rather than one per frame. Everything that is not a drag (typed values, resets, applying a Look, undo, window resizes) rebuilds immediately, which is what a settle timer would have delayed for no reason. `colorCorrect` multiplies the accumulated bloom and flare terms by it +- **Depth of Field:** three passes — circle of confusion (`gDeferredCoFProgram`, writing sharp linear colour plus signed CoF in alpha to `dofSharp`), a gather blur at `CameraDoFResScale` into `dofBlur`, and a combine (`gDeferredDoFCombineProgram`) back over `mRT->screen` under `setColorMask(true, false)` so the legacy prim-glow alpha tag survives. The gather has four compile-time variants over two axes, `FRONT_BLUR` × `DOF_SHAPED`: `gDeferredPostProgram`, `gDeferredPostProgramNoNear`, `gDeferredPostProgramShaped`, `gDeferredPostProgramNoNearShaped`. Optics: `CameraFNumber`, `CameraFocalLength`, `CameraFieldOfView`, `CameraMaxCoF`, `CameraDoFResScale` +- **Bokeh shaping** (inside the `DOF_SHAPED` gather): polygonal aperture (`RenderBokehApertureBlades`, `Rotation`, `Curvature`), anamorphic squeeze, cat's-eye optical vignetting, defocus fringing, and the lens aberrations — spherical (`RenderBokehSphericalAberration`), field stretch for swirl and coma (`RenderBokehFieldStretch`, `FieldFalloff`) and comatic asymmetry. The CPU picks a shaped variant only when one of them is actually doing something - **Screen Space Reflections:** Class 3+ feature, iterative ray marching - **Tonemapping:** ACES, Reinhard, Filmic, AGX — selectable via `AlchemyRenderTonemapType` - **Color Grading:** 3D LUT-based (`gDeferredPostGammaCorrectCGLutProgram`, `mCGLut`) - **Anti-aliasing:** FXAA (1-pass, `gFXAAProgram[4]`) or SMAA (3-pass edge detect → blend weights → neighborhood blend, `gSMAAEdgeDetectProgram[4]`/`gSMAABlendWeightsProgram[4]`/`gSMAANeighborhoodBlendProgram[4]`). CAS (Contrast Adaptive Sharpening) via `gCASProgram` -- **Final blit effects** (`blitWithEffectsF.glsl` in `shaders/class1/alchemy/`): Vignette (configurable shape/softness/color), film grain (luma/color/coarse/photon styles), TPDF dithering, CVD compensation/preview -- **Chromatic aberration** (`colorCorrectF.glsl` in `shaders/class1/alchemy/`): Per-channel offset with amount, falloff, angle, anisotropy controls +- **Final blit effects** (`blitWithEffectsF.glsl` in `shaders/class1/alchemy/`): geometric lens distortion (Brown-Conrady radial `k1`/`k2` and tangential `p1`/`p2`, anamorphic squeeze, decentring, with the auto-fit rescale solved on the CPU and out-of-frame samples masked to black), vignette (configurable shape/softness/color), film grain (luma/color/coarse/photon styles), TPDF dithering, CVD compensation/preview. Distortion warps the world view only — overlays drawn after the blit do not follow it +- **Effects inside `colorCorrectF.glsl`** (`shaders/class1/alchemy/`), which runs in every variant including the no-post ones and so gates them itself: chromatic aberration (per-channel offset with amount, falloff, angle, anisotropy), the five-component lens flare, the bloom and cross-filter composite, and lens dirt — the generated grime plate above, multiplied by the accumulated flare and bloom terms, so it only lights up where something already is Post-processing settings are exposed in the Lightbox floater (`ALFloaterLightBox`); see `doc/LIGHTBOX.md` for how to add UI sections for new effects. @@ -126,9 +132,9 @@ GLSL shaders live in `indra/newview/app_settings/shaders/` organized by quality - **`class1/`** — Base shaders (all hardware) - **`class2/`** — Mid-tier features - **`class3/`** — Advanced features (SSR, high-quality lighting) -- Categories: `deferred/`, `objects/`, `environment/`, `alchemy/` (Alchemy post-processing), `windlight/`, `avatar/`, `interface/` +- Categories: `deferred/`, `objects/`, `environment/`, `alchemy/` (Alchemy post-processing), `effects/` (glow, the bloom pyramid, the cross-screen filter), `windlight/`, `avatar/`, `interface/` -Key shader groups: GBuffer write (`gDeferredDiffuseProgram`, `gDeferredPBROpaqueProgram`, `gDeferredBumpProgram`, `gDeferredMaterialProgram[]`, etc.), deferred lighting (`gDeferredSunProgram`, `gDeferredLightProgram`, `gDeferredMultiLightProgram[16]`, `gDeferredSpotLightProgram`), shadows (`gDeferredShadowProgram` and variants), environment (`gDeferredWLSkyProgram`, `gWaterProgram`, `gHazeProgram`), post-processing (tonemap, FXAA, SMAA, CAS, bloom, DoF programs). +Key shader groups: GBuffer write (`gDeferredDiffuseProgram`, `gDeferredPBROpaqueProgram`, `gDeferredBumpProgram`, `gDeferredMaterialProgram[]`, etc.), deferred lighting (`gDeferredSunProgram`, `gDeferredLightProgram`, `gDeferredMultiLightProgram[16]`, `gDeferredSpotLightProgram`), shadows (`gDeferredShadowProgram` and variants), environment (`gDeferredWLSkyProgram`, `gWaterProgram`, `gHazeProgram`), post-processing (tonemap, FXAA, SMAA, CAS, the bloom pyramid programs, `gCrossFilterProgram`, and the four-variant DoF gather set). ### Draw Pool & Spatial System diff --git a/doc/LIGHTBOX.md b/doc/LIGHTBOX.md index 50dde645fe..1af4b81e57 100644 --- a/doc/LIGHTBOX.md +++ b/doc/LIGHTBOX.md @@ -100,8 +100,35 @@ The floater's C++ provides: - `LightBox.CommitSplitToneGraph` — the split-tone band graph's handle, which writes `RenderSplitToneBalance`. - `LightBox.PickWhiteBalance` — arms the eyedropper. +- `LightBox.OpenLUTFolder` — reveals the user's colour-LUT folder, creating it + on first use. - The Looks bar and tonemapper-row greying (effect-specific, already done). +**Asset-picker rows are a third kind of dropdown**, distinct from the enum +recipe below. They bind a `combo_box allow_text_entry="true"` to a *string* +setting naming a file, and are filled from C++ by +`populateAssetCombo(combo_name, dir_name, extensions, setting_name)`: bundled +entries from `app_settings/` first, then the user's own from +`user_settings/` behind a separator, matching the order the renderer +itself resolves names in. Four things are not optional. The XUI carries one +literal `` and nothing else. `postBuild` +must call the populate helper, because the list does not exist until it does. +The helper's closing `selectByValue` is load-bearing rather than cosmetic — +with `allow_text_entry` nothing else restores the saved value when the floater +opens. And the extension whitelist must list only what the loader can actually +decode, or the picker offers files that silently fail. Pair it with an +`openUserAssetFolder(dir_name)` button so the folder is discoverable. + +The colour LUT is currently the only picker — the lens dirt plate that shared +these helpers is generated now. Both stay parameterised by directory anyway, +because the shape is the shared part and collapsing them back to a constant +only has to be undone for the next asset. Before reaching for a picker at all, +ask whether the asset could be generated instead: a texture a shader can draw +into a render target once needs no file, no packaging entry, no extension +whitelist and no fitting to the window, and it can be put on sliders. See +`generateLensDirt` in pipeline.cpp for the shape — gate, cached parameter set, +allocate, draw, release when the effect goes off. + The last four are examples of the per-control cost: a graph or a tool needs something to interpret its input, so it gets one callback and one `setup*` call in `postBuild`. Both graphs follow the same shape — `setupX` connects to @@ -811,13 +838,20 @@ only; apply per row, not on the parent panel. Reference patterns: The snapshot floater's "No post-processing" box has to mean it, so a new *print* effect must check it wherever its strength is uploaded — there are `clean_plate` gates in **two** places, because post-grade is two passes. - Effects in the final blit (vignette, grain, CVD, the preview modes) gate in - `renderFinalize`; effects applied *inside* the colorCorrect program - (chromatic aberration, lens flare) gate in `colorCorrect`, because they run - in every variant including the no-post ones — those two leaked through the - first time for exactly that reason. The one deliberate exception is dither, + Effects in the final blit (lens distortion, vignette, grain, CVD, the preview + modes) gate in `renderFinalize`; effects applied *inside* the colorCorrect + program (chromatic aberration, lens flare, lens dirt, the cross-filter + composite) gate in `colorCorrect`, because they run in every variant + including the no-post ones — the first two leaked through the first time for + exactly that reason. The one deliberate exception is dither, which is a quantisation aid rather than a look and which an 8-bit PNG wants either way. +- **A generation pass is a third case, and gating it is a mistake.** + `generateLensDirt` deliberately ignores `gSnapshotNoPost`: the flag is true + for the single frame a no-post snapshot is taken, so releasing the plate for + it would buy a full regeneration on the very next frame — a hitch every time + someone takes one. What has to be gated is the *use* of the plate, in + `colorCorrect`, not its production. Two things that only show up on screen, both of which did: @@ -856,6 +890,15 @@ keys, `Persist=0` keys, structural buffer-shape knobs, or debug toggles. A startup `LL_WARNS("Presets")` fires for whitelist names that stop existing, so renames get caught. +`audit_bundled_looks()` runs from the `LLPresetsManager` constructor and checks +the bundled Looks two ways: any whitelisted key a Look is missing, and any key +whose stored `Comment` no longer matches the live setting's. The second is a +maintenance rule worth stating plainly — **rewording a setting's `Comment` in +`settings_alchemy.xml` obliges you to re-save the three bundled Looks**, which +each carry their own copy that nothing reads. Five keys had already drifted +that way before the check existed, silently, because presence was all anything +verified. + Bundled starter Looks live in `app_settings/looks/` as full whitelist snapshots ({Comment, Persist, Type, Value} per key, URI-escaped filenames). **Add your keys to all three at their defaults**, or applying a bundled Look diff --git a/indra/llrender/llshadermgr.cpp b/indra/llrender/llshadermgr.cpp index 2b40c59480..989d0e2171 100644 --- a/indra/llrender/llshadermgr.cpp +++ b/indra/llrender/llshadermgr.cpp @@ -1991,6 +1991,60 @@ void LLShaderMgr::initAttribsAndUniforms() mReservedUniforms.push_back("uRefWipeMode"); mReservedUniforms.push_back("uRefWipePos"); + // Geometric lens distortion + mReservedUniforms.push_back("uLensDistortAmount"); + mReservedUniforms.push_back("uLensDistortK"); + mReservedUniforms.push_back("uLensDistortScale"); + mReservedUniforms.push_back("uLensDistortSqueeze"); + mReservedUniforms.push_back("uLensDistortCenter"); + mReservedUniforms.push_back("uLensDistortTangential"); + + // Bokeh + mReservedUniforms.push_back("uBokehHighlightThreshold"); + mReservedUniforms.push_back("uBokehHighlightGain"); + mReservedUniforms.push_back("uBokehHighlightClamp"); + mReservedUniforms.push_back("uBokehBlades"); + mReservedUniforms.push_back("uBokehApertureRotation"); + mReservedUniforms.push_back("uBokehApertureCurvature"); + mReservedUniforms.push_back("uBokehApertureConst"); + mReservedUniforms.push_back("uBokehAnamorphic"); + mReservedUniforms.push_back("uBokehCatEye"); + mReservedUniforms.push_back("uBokehFringeAmount"); + mReservedUniforms.push_back("uBokehFringeNearTint"); + mReservedUniforms.push_back("uBokehFringeFarTint"); + + // Lens dirt + mReservedUniforms.push_back("uLensDirtMap"); + mReservedUniforms.push_back("uLensDirtStrength"); + mReservedUniforms.push_back("uLensDirtBloomResponse"); + mReservedUniforms.push_back("uLensDirtFlareResponse"); + + // Lens dirt generation + mReservedUniforms.push_back("uDirtResolution"); + mReservedUniforms.push_back("uDirtSeed"); + mReservedUniforms.push_back("uDirtGrime"); + mReservedUniforms.push_back("uDirtMoteScale"); + mReservedUniforms.push_back("uDirtSmudge"); + mReservedUniforms.push_back("uDirtScratches"); + mReservedUniforms.push_back("uDirtToe"); + mReservedUniforms.push_back("uDirtGain"); + + // Cross-screen filter + mReservedUniforms.push_back("uCrossTexel"); + mReservedUniforms.push_back("uCrossDir"); + mReservedUniforms.push_back("uCrossLength"); + mReservedUniforms.push_back("uCrossFalloff"); + mReservedUniforms.push_back("uCrossChromatic"); + mReservedUniforms.push_back("uCrossPassScale"); + mReservedUniforms.push_back("uCrossStrength"); + + mReservedUniforms.push_back("uBokehSpherical"); + mReservedUniforms.push_back("uBokehFieldStretch"); + mReservedUniforms.push_back("uBokehFieldFalloff"); + mReservedUniforms.push_back("uBokehComaAsymmetry"); + + mReservedUniforms.push_back("crossFilterMap"); + // Text Shadow mReservedUniforms.push_back("textShadowMode"); diff --git a/indra/llrender/llshadermgr.h b/indra/llrender/llshadermgr.h index 1d2aa00401..2e26e7a7c9 100644 --- a/indra/llrender/llshadermgr.h +++ b/indra/llrender/llshadermgr.h @@ -519,6 +519,68 @@ class LLShaderMgr REFERENCE_WIPE_MODE, // "uRefWipeMode" REFERENCE_WIPE_POS, // "uRefWipePos" + // Geometric lens distortion — Brown-Conrady, applied in the final blit. + // New families are appended here rather than inserted among the blocks + // above: this list and the string table in llshadermgr.cpp are parallel + // and ordinal-coupled, so appending keeps every later index stable. + LENS_DISTORT_AMOUNT, // "uLensDistortAmount" master gate; 0 = off + LENS_DISTORT_K, // "uLensDistortK" (k1, k2) pre-multiplied by amount on CPU + LENS_DISTORT_SCALE, // "uLensDistortScale" auto-fit rescale (direct multiplier), solved on CPU + LENS_DISTORT_SQUEEZE, // "uLensDistortSqueeze" (1 / squeeze, 1) pre-reciprocated on CPU + LENS_DISTORT_CENTER, // "uLensDistortCenter" decentering offset from frame centre + LENS_DISTORT_TANGENTIAL, // "uLensDistortTangential" (p1, p2) pre-multiplied by amount on CPU + + // Bokeh — depth of field gather weighting (postDeferredF) + BOKEH_HIGHLIGHT_THRESHOLD, // "uBokehHighlightThreshold" luma where the boost starts + BOKEH_HIGHLIGHT_GAIN, // "uBokehHighlightGain" 0 = plain average, the fast path + BOKEH_HIGHLIGHT_CLAMP, // "uBokehHighlightClamp" per-sample radiance ceiling; <= 0 disables + + // Bokeh — shaped aperture and defocus fringing (DOF_SHAPED builds only) + BOKEH_BLADES, // "uBokehBlades" 0 = circular, 3..11 = polygon + BOKEH_APERTURE_ROTATION, // "uBokehApertureRotation" radians, converted from degrees on CPU + BOKEH_APERTURE_CURVATURE, // "uBokehApertureCurvature" 0 straight blades, 1 fully round + BOKEH_APERTURE_CONST, // "uBokehApertureConst" (pi/N, 2pi/N, cos(pi/N)) baked on CPU + BOKEH_ANAMORPHIC, // "uBokehAnamorphic" area-preserving (x, y) stretch baked on CPU + BOKEH_CAT_EYE, // "uBokehCatEye" optical vignetting strength + BOKEH_FRINGE_AMOUNT, // "uBokehFringeAmount" + BOKEH_FRINGE_NEAR_TINT, // "uBokehFringeNearTint" + BOKEH_FRINGE_FAR_TINT, // "uBokehFringeFarTint" + + // Lens dirt — grime on the front element, lit by bloom and flare + LENS_DIRT_MAP, // "uLensDirtMap" + LENS_DIRT_STRENGTH, // "uLensDirtStrength" 0 disables; forced to 0 when no plate loaded + LENS_DIRT_BLOOM_RESPONSE, // "uLensDirtBloomResponse" + LENS_DIRT_FLARE_RESPONSE, // "uLensDirtFlareResponse" + + // Lens dirt generation — read only by the plate generator, which runs + // when a parameter moves rather than per frame + LENS_DIRT_RESOLUTION, // "uDirtResolution" plate size; only the ratio is read + LENS_DIRT_SEED, // "uDirtSeed" + LENS_DIRT_GRIME, // "uDirtGrime" master density + LENS_DIRT_MOTE_SCALE, // "uDirtMoteScale" + LENS_DIRT_SMUDGE, // "uDirtSmudge" + LENS_DIRT_SCRATCHES, // "uDirtScratches" 0 for undamaged glass + LENS_DIRT_TOE, // "uDirtToe" tone curve exponent + LENS_DIRT_GAIN, // "uDirtGain" tone curve gain + + // Cross-screen (star) filter — streaks every thresholded highlight + CROSS_TEXEL, // "uCrossTexel" 1 / source size + CROSS_DIR, // "uCrossDir" unit arm direction, one chain per arm + CROSS_LENGTH, // "uCrossLength" + CROSS_FALLOFF, // "uCrossFalloff" + CROSS_CHROMATIC, // "uCrossChromatic" + CROSS_PASS_SCALE, // "uCrossPassScale" 1, 4, 16 across the passes + CROSS_STRENGTH, // "uCrossStrength" 1.0 until the final composite + + // Lens aberrations -- bokeh shape contributed by the glass rather than + // by the iris, so these sit alongside the aperture controls above + BOKEH_SPHERICAL, // "uBokehSpherical" signed; the sign flips across focus + BOKEH_FIELD_STRETCH, // "uBokehFieldStretch" + tangential (swirl), - radial (coma) + BOKEH_FIELD_FALLOFF, // "uBokehFieldFalloff" exponent on normalised field radius + BOKEH_COMA_ASYMMETRY, // "uBokehComaAsymmetry" + + CROSS_FILTER_MAP, // "crossFilterMap" streak accumulator, composited in colorCorrect + // End Alchemy Effects Stack TEXT_SHADOW_MODE, // "textShadowMode" diff --git a/indra/newview/alfloaterlightbox.cpp b/indra/newview/alfloaterlightbox.cpp index 5bcc1a6375..120c06c423 100644 --- a/indra/newview/alfloaterlightbox.cpp +++ b/indra/newview/alfloaterlightbox.cpp @@ -177,6 +177,8 @@ ALFloaterLightBox::ALFloaterLightBox(const LLSD& key) mCommitCallbackRegistrar.add("LightBox.CommitSplitToneGraph", std::bind(&ALFloaterLightBox::onCommitSplitToneGraph, this)); mCommitCallbackRegistrar.add("LightBox.PickWhiteBalance", std::bind(&ALFloaterLightBox::onClickWhiteBalancePicker, this)); mCommitCallbackRegistrar.add("LightBox.OpenLUTFolder", std::bind(&ALFloaterLightBox::onClickOpenLUTFolder, this)); + mCommitCallbackRegistrar.add("LightBox.LensDirtSliderDown", std::bind(&ALFloaterLightBox::onLensDirtSliderHeld, this, true)); + mCommitCallbackRegistrar.add("LightBox.LensDirtSliderUp", std::bind(&ALFloaterLightBox::onLensDirtSliderHeld, this, false)); mCommitCallbackRegistrar.add("LightBox.LookSelected", std::bind(&ALFloaterLightBox::onLookSelected, this)); mCommitCallbackRegistrar.add("LightBox.LookSave", std::bind(&ALFloaterLightBox::onClickLookSave, this)); mCommitCallbackRegistrar.add("LightBox.LookSaveAs", std::bind(&ALFloaterLightBox::onClickLookSaveAs, this)); @@ -283,26 +285,33 @@ bool ALFloaterLightBox::postBuild() return LLFloater::postBuild(); } -void ALFloaterLightBox::populateLUTCombo() +// Shared by the colour LUT and lens dirt pickers. Both enumerate a bundled +// directory and a user directory of the same name, list the bundled entries +// first and the user ones behind a separator, and select whatever the setting +// currently holds. Generalised rather than cloned: the two differ only in +// directory, accepted extensions, and which setting they write. +// +// `extensions` must list only what the corresponding loader can actually +// handle. Anything else in the directory -- a readme, a subfolder, a stray +// .bak -- would become a selectable entry that fails at apply time with +// nothing but a log line to say why. getExtension lowercases, so a .CUBE +// passes here the same way it does when the renderer resolves it. +void ALFloaterLightBox::populateAssetCombo(const std::string& combo_name, + const std::string& dir_name, + const std::vector& extensions, + const std::string& setting_name) { - LLComboBox* lut_combo = getChild("colorlut_combo"); - - // Only what setupGradingLUT can actually load. Anything else in the - // directory -- a readme, a subfolder, a stray .bak -- would become a - // selectable entry that fails at apply time with nothing but a log line - // to say why. getExtension lowercases, so a .CUBE passes here the same - // way it does when the renderer resolves it. - static const char* const LUT_EXTENSIONS[] = { "cube", "tga", "png", "jpg", "jpeg", "bmp", "webp" }; + LLComboBox* combo = getChild(combo_name); // Collected rather than added on the spot, so the caller can see whether // a directory contributed anything before committing to the separator. - auto collect_luts_from = [](const std::string& dir_name) + auto collect_from = [&extensions](const std::string& scan_dir) { std::vector> found; // stem, filename std::error_code ec; - std::filesystem::path luts_path = fsyspath(dir_name); - if (!std::filesystem::is_directory(luts_path, ec) || ec) + std::filesystem::path scan_path = fsyspath(scan_dir); + if (!std::filesystem::is_directory(scan_path, ec) || ec) { return found; } @@ -312,67 +321,97 @@ void ALFloaterLightBox::populateLUTCombo() // parks the iterator at end instead, which is why ec is looked at // again once the loop is done. std::filesystem::directory_iterator end; - for (std::filesystem::directory_iterator lut(luts_path, ec); lut != end && !ec; lut.increment(ec)) + for (std::filesystem::directory_iterator entry(scan_path, ec); entry != end && !ec; entry.increment(ec)) { std::error_code entry_ec; - if (!lut->is_regular_file(entry_ec) || entry_ec) + if (!entry->is_regular_file(entry_ec) || entry_ec) { continue; } #if LL_WINDOWS - std::string lut_stem = ll_convert_wide_to_string(lut->path().stem().native()); - std::string lut_filename = ll_convert_wide_to_string(lut->path().filename().native()); + std::string entry_stem = ll_convert_wide_to_string(entry->path().stem().native()); + std::string entry_filename = ll_convert_wide_to_string(entry->path().filename().native()); #else - std::string lut_stem = lut->path().stem().native(); - std::string lut_filename = lut->path().filename().native(); + std::string entry_stem = entry->path().stem().native(); + std::string entry_filename = entry->path().filename().native(); #endif - const std::string exten = gDirUtilp->getExtension(lut_filename); - if (std::find(std::begin(LUT_EXTENSIONS), std::end(LUT_EXTENSIONS), exten) == std::end(LUT_EXTENSIONS)) + const std::string exten = gDirUtilp->getExtension(entry_filename); + if (std::find(extensions.begin(), extensions.end(), exten) == extensions.end()) { continue; } - found.emplace_back(std::move(lut_stem), std::move(lut_filename)); + found.emplace_back(std::move(entry_stem), std::move(entry_filename)); } if (ec) { - LL_WARNS() << "Error reading LUT directory " << dir_name << ": " << ec.message() << LL_ENDL; + LL_WARNS() << "Error reading asset directory " << scan_dir << ": " << ec.message() << LL_ENDL; } return found; }; - // Bundled LUTs first, then user LUTs behind a separator — the same order + // Bundled entries first, then user entries behind a separator — the same order // the renderer resolves a name in, where the user dir wins. - for (const auto& lut : collect_luts_from(gDirUtilp->getExpandedFilename(LL_PATH_APP_SETTINGS, "colorlut"))) + for (const auto& entry : collect_from(gDirUtilp->getExpandedFilename(LL_PATH_APP_SETTINGS, dir_name))) { - lut_combo->add(lut.first, lut.second); + combo->add(entry.first, entry.second); } - const auto user_luts = collect_luts_from(gDirUtilp->getExpandedFilename(LL_PATH_USER_SETTINGS, "colorlut")); - if (!user_luts.empty()) + const auto user_entries = collect_from(gDirUtilp->getExpandedFilename(LL_PATH_USER_SETTINGS, dir_name)); + if (!user_entries.empty()) { - lut_combo->addSeparator(); - for (const auto& lut : user_luts) + combo->addSeparator(); + for (const auto& entry : user_entries) { - lut_combo->add(lut.first, lut.second); + combo->add(entry.first, entry.second); } } - lut_combo->selectByValue(gSavedSettings.getString("RenderColorGradeLUT")); - lut_combo->resetDirty(); + // Mandatory: the combo is allow_text_entry, so nothing else selects the + // saved value when the floater opens. + combo->selectByValue(gSavedSettings.getString(setting_name)); + combo->resetDirty(); } -void ALFloaterLightBox::onClickOpenLUTFolder() +void ALFloaterLightBox::populateLUTCombo() +{ + static const std::vector LUT_EXTENSIONS = { "cube", "tga", "png", "jpg", "jpeg", "bmp", "webp" }; + populateAssetCombo("colorlut_combo", "colorlut", LUT_EXTENSIONS, "RenderColorGradeLUT"); +} + +// Parameterised by directory even though the colour LUT is once again the only +// caller: the lens dirt plate that shared it is generated now. Kept general +// because the shape is the shared part -- user folder, created on demand -- and +// collapsing it back to a constant only has to be undone for the next asset. +void ALFloaterLightBox::openUserAssetFolder(const std::string& dir_name) { // The user's folder, not the bundled one: it is the half of the pair that // is theirs to put files in, and the one the renderer prefers when a name // exists in both. Nothing creates it until there is something to put in // it, which is exactly now -- and LLFile::mkdir is quiet about a // directory that already exists. - const std::string dir = gDirUtilp->getExpandedFilename(LL_PATH_USER_SETTINGS, "colorlut"); + const std::string dir = gDirUtilp->getExpandedFilename(LL_PATH_USER_SETTINGS, dir_name); LLFile::mkdir(dir); gDirUtilp->openDir(dir); } +// The lens dirt plate is regenerated whenever one of its parameters changes, +// and at full resolution that is too expensive to do on every frame of a slider +// drag. Rather than guess when a drag has ended from a timer, say so: the +// renderer holds off while this is raised and rebuilds once on release. +// +// Only the generation sliders carry these. Strength and the two response +// controls are applied per frame at composite time and rebuild nothing, so +// holding them off would only make them feel broken. +void ALFloaterLightBox::onLensDirtSliderHeld(bool held) +{ + gPipeline.mLensDirtSliderHeld = held; +} + +void ALFloaterLightBox::onClickOpenLUTFolder() +{ + openUserAssetFolder("colorlut"); +} + void ALFloaterLightBox::onClickResetControlDefault(const LLSD& userdata) { const std::string& control_name = userdata.asString(); diff --git a/indra/newview/alfloaterlightbox.h b/indra/newview/alfloaterlightbox.h index 7b835447a7..dc24464921 100644 --- a/indra/newview/alfloaterlightbox.h +++ b/indra/newview/alfloaterlightbox.h @@ -92,10 +92,16 @@ class ALFloaterLightBox final : public LLFloater /// Arm the scene picker; the click that follows sets Temperature and Tint. void onClickWhiteBalancePicker(); void onWhiteBalancePicked(const LLColor3& sample); + void populateAssetCombo(const std::string& combo_name, + const std::string& dir_name, + const std::vector& extensions, + const std::string& setting_name); void populateLUTCombo(); + void openUserAssetFolder(const std::string& dir_name); /// Open the user's LUT folder in the platform file browser, creating it /// first if this is its first use. void onClickOpenLUTFolder(); + void onLensDirtSliderHeld(bool held); void updateTonemapperRows(); /// Freeze the frame about to be presented, and switch the wipe on so the /// grab is visibly a grab. diff --git a/indra/newview/app_settings/looks/Golden%20Hour.xml b/indra/newview/app_settings/looks/Golden%20Hour.xml index bd19ada4a7..025fffcce8 100644 --- a/indra/newview/app_settings/looks/Golden%20Hour.xml +++ b/indra/newview/app_settings/looks/Golden%20Hour.xml @@ -74,7 +74,7 @@ RenderBloomScatter Comment - Bloom upsample tent-filter radius multiplier. 1.0 = adjacent-texel taps. Values >1 widen the bloom per octave; >~2.5 starts to alias because the tent skips source texels. + Bloom upsample tent-filter radius multiplier. 1.0 = adjacent-texel taps. Values >1 widen the bloom per octave; >~2.5 starts to alias because the tent skips source texels. Persist 1 Type @@ -104,6 +104,168 @@ Value 2.5 + RenderBokehAnamorphicSqueeze + + Comment + Stretches out-of-focus highlights into ovals, the way the cylindrical element of an anamorphic lens does. 1.0 is a round spherical-lens disc. Above 1 gives taller-than-wide ovals, the classic anamorphic signature; below 1 gives wider-than-tall. The stretch preserves area, so this changes the shape of the blur without changing how strong it is. Range 0.25 to 4. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderBokehApertureBlades + + Comment + Number of aperture blades shaping the out-of-focus highlights. 0 gives the perfectly round bokeh of an idealised lens. 5 to 9 reproduce real diaphragms, where the discs become visible polygons - 6 is the most common on stills lenses. Values below 3 are treated as circular. Range 0 to 11. + Persist + 1 + Type + S32 + Value + 0 + + RenderBokehApertureCurvature + + Comment + Rounds the aperture blades back toward a circle. 0 gives hard straight-edged polygons; 1 is fully round, matching a rounded diaphragm held wide open. Only has an effect when Aperture Blades is 3 or more. Range 0 to 1. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderBokehApertureRotation + + Comment + Rotation of the aperture polygon in degrees. Only has an effect when Aperture Blades is 3 or more. Range 0 to 360. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderBokehCatEyeAmount + + Comment + Optical vignetting, also called cat's eye. Toward the frame edges the lens barrel clips the aperture, squeezing round bokeh into lens-shaped slivers that lean away from the centre while the middle of the frame stays round. Strength follows distance from the centre of the image circle, so on a wide display the sides clip well before the top and bottom do. 0 disables. 0.45 is a natural fast-prime look; 1.0 is heavy. The range runs past the old maximum because measuring from the image circle lowered the offset everywhere -- a value tuned before that change wants roughly half again as much. Range 0 to 1.5. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderBokehComaAsymmetry + + Comment + Comatic asymmetry - draws each out-of-focus highlight into a comet whose tail points away from the centre of frame, the way a real lens flares off-axis. Zero on the optical axis and strongest in the corners, and it follows the highlight's actual shape, so it stays aligned with an anamorphic squeeze or a swirl rather than drifting off them. 0 disables. Pairs with RenderBokehFieldStretch, which supplies the elongation the tail runs along. Range 0 to 1. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderBokehFieldFalloff + + Comment + How quickly field aberration builds from the centre of the frame toward the corners. 1 spreads it fairly evenly across the frame; higher values keep the middle clean and concentrate the deformation at the edges. Shape control only; use RenderBokehFieldStretch to toggle the effect. Range 1 to 4. + Persist + 1 + Type + F32 + Value + 2.0 + + RenderBokehFieldStretch + + Comment + Field aberration - stretches out-of-focus highlights toward the frame edges while the centre stays round. 0 disables. Positive stretches them across the radius so they line up along circles and the frame appears to swirl, the look of old fast portrait glass; negative stretches them along the radius into comet shapes. Area-preserving, so highlights change shape without changing brightness. Range -1 to 1. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderBokehFringeAmount + + Comment + Longitudinal chromatic aberration - the colour cast real glass leaves on the rim of an out-of-focus highlight, which flips hue either side of the focal plane. 0 disables. 0.2 is a subtle uncorrected-lens feel; 0.6 is strong. This is separate from the frame-wide fringe under Chromatic Aberration, which appears whether or not anything is defocused. Range 0 to 1. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderBokehFringeFarTint + + Comment + Colour cast on highlights behind the focal plane. The default light green is the counterpart to the near tint; a real lens shifts hue in opposite directions either side of focus. + Persist + 1 + Type + Color3 + Value + + 0.85 + 1.0 + 0.9 + + + RenderBokehFringeNearTint + + Comment + Colour cast on highlights in front of the focal plane. The default light magenta matches the usual signature of uncorrected glass; swap with the far tint to reverse the effect. + Persist + 1 + Type + Color3 + Value + + 1.0 + 0.85 + 1.0 + + + RenderBokehHighlightGain + + Comment + Extra weight given to bright samples inside the defocus blur, which makes out-of-focus highlights read as distinct bokeh discs rather than a smooth blur. 0 is a plain average and is also the cheapest. 0.5 is a gentle lift; 2.0 is a strong cinematic pop. Range 0 to 4. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderBokehHighlightThreshold + + Comment + Brightness above which the highlight boost starts to apply. 0 boosts the whole range; raising it restricts the effect to genuine highlights and leaves midtones averaging normally. Only has an effect when Highlight Gain is above 0. Range 0 to 8. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderBokehSphericalAberration + + Comment + Spherical aberration - how the light inside an out-of-focus highlight is spread across it. 0 is the evenly lit disc of a perfectly corrected lens. Positive brightens the rim and hollows out the middle, the soap-bubble look; negative fills the middle and softens the edge, the creamy look. The sign flips either side of the focal plane exactly as it does in real glass, so a lens with bright-rimmed background bokeh has bright-centred foreground bokeh. Needs a few pixels of blur before there is any shape to work with, and fades out below that. Range -1 to 1. + Persist + 1 + Type + F32 + Value + 0.0 + RenderCASSharpness Comment @@ -228,7 +390,7 @@ RenderColorGradeBrightness Comment - Shifts the whole image lighter or darker. Range -0.5 to 0.5; small values (±0.05) are already noticeable. + Shifts the whole image lighter or darker. Range -0.5 to 0.5; small values (±0.05) are already noticeable. Persist 1 Type @@ -447,6 +609,72 @@ Value 1.0 + RenderCrossFilterAngle + + Comment + Rotation of the whole star in degrees, as if turning the filter in its thread. Range 0 to 360. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderCrossFilterChromatic + + Comment + Rainbow dispersion along the arms. A real star filter is a diffraction grating, and a grating separates wavelengths by angle, so the tips of the streaks go coloured while the core stays white. 0 gives clean white spokes. Range 0 to 1. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderCrossFilterFalloff + + Comment + How quickly each arm fades along its length, measured as how much brightness is lost between the core of a highlight and the tip of its arm. Low values give long even spokes; high values keep the star tight around the highlight. This changes only the shape of the arms - brightness belongs to Strength. Range 0.1 to 3. + Persist + 1 + Type + F32 + Value + 1.5 + + RenderCrossFilterLength + + Comment + Spacing of the samples that build each arm, which sets how far the star reaches. 1.0 is the tuned value and gives a clean continuous streak; higher spreads the arms further, and lower draws them in tight. The range stops at 2 because beyond that the samples building each arm stop overlapping and the streak breaks up into a repeating lattice rather than getting longer. Length and falloff work together - a long streak with a fast falloff still fades out early, which is usually what reads best. Range 0.25 to 2. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderCrossFilterPoints + + Comment + Number of arms on each star. 4 is the classic cross filter, 6 and 8 match the denser gratings. Odd counts give an asymmetric star, since the arms are spread evenly rather than in opposed pairs. Range 2 to 12. + Persist + 1 + Type + S32 + Value + 4 + + RenderCrossFilterStrength + + Comment + Strength of the cross-screen (star) filter - the etched glass filter that diffracts every bright point in frame into a star. 0 disables. The arms carry a highlight's light spread along their length, so they are much dimmer than the core and this generally wants a large value - try 8 and work outward. Unlike the starburst under Lens Flare, which is locked to the sun, this streaks every highlight. Streaks ride on the bloom pyramid, so bloom strength 0 silences them. HDR only. Range 0 to 32. + Persist + 1 + Type + F32 + Value + 0.0 + RenderDynamicExposureCoefficient Comment @@ -701,6 +929,201 @@ Value 1.3 + RenderLensDirtBloomResponse + + Comment + How strongly bloom lights up the dirt. 0 means bloom passes through a clean lens; 1 is the full response. Raise it for hazy sunlit shots where the grime should catch everything. Range 0 to 2. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderLensDirtFlareResponse + + Comment + How strongly the lens flare lights up the dirt. 0 means the flare passes through a clean lens; 1 is the full response. This is the response that reads most like a real lens, since flare and grime share the same front element. Range 0 to 2. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderLensDirtGain + + Comment + Overall brightness of the generated dirt plate, applied after the tone curve. Mostly a trim for Toe: raise it when a high Toe has crushed the plate too far. Range 0.5 to 2.5. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderLensDirtGrime + + Comment + How much muck is on the glass. Scales every layer of dust and grit at once, so this is the master density. Starting points, with Smudge and Toe to match: a lens that has been outdoors 0.45 / 0.5 / 1.8, one nobody has wiped in a while 1.0 / 1.0 / 1.6, a damaged one 0.95 / 0.9 / 1.2 with scratches, and filthy 1.9 / 1.5 / 1.2. Range 0 to 2. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderLensDirtMoteScale + + Comment + Size of the dust motes. Above 1 the specks grow and thin out, which reads as a lens that has picked up a few big flecks rather than an even film; below 1 they shrink and multiply into finer grime. Range 0.5 to 2. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderLensDirtScratches + + Comment + How many scratches cross the glass. 0 is undamaged; around 16 reads as a well-used lens with coating chips. Scratches are straight and bright where dust is soft and scattered, so a few go a long way. Range 0 to 32. + Persist + 1 + Type + S32 + Value + 0 + + RenderLensDirtSeed + + Comment + Reshuffles the grime - every value lays the dirt out differently at the same settings. It also moves the overall density by twenty or thirty percent, because dirt pools rather than spreading evenly and where it pools changes with the seed. If a plate comes out dirtier than the last, that is this and not Grime. Range 0 to 999. + Persist + 1 + Type + S32 + Value + 7 + + RenderLensDirtSmudge + + Comment + Strength of the wipe marks - the broad, soft smears a cloth leaves behind, as opposed to the discrete specks. Raise it for a lens cleaned in a hurry. Range 0 to 2. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderLensDirtStrength + + Comment + Strength of the lens dirt overlay - grime on the front element catching bloom and flare, the way a real lens lights up when something bright is in frame. 0 disables. 0.3 is a subtle used-lens feel; 1.0 is a filthy one. Only lights up where there is already bloom or flare to catch, so a flat scene stays clean no matter how high this goes. Range 0 to 2. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderLensDirtToe + + Comment + Shapes the low end of the plate - how much of the faint grime survives into the result. Lower values keep the midtones dirt actually lives in; higher values crush them and leave only the brightest specks, which reads clean no matter how high Strength goes. Range 0.6 to 4. + Persist + 1 + Type + F32 + Value + 1.6 + + RenderLensDistortionAmount + + Comment + Overall strength of the geometric lens distortion. 0 disables the effect entirely. 0.25 is a subtle wide-angle bend; 1.0 applies the shape coefficients in full. Only the world view is warped - nametags, selection outlines and UI are drawn afterwards and stay straight. Range 0 to 1. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderLensDistortionCenter + + Comment + [-0.5, 0.5] Offset of the optical axis from the frame center, x and y, z component unused. Non-zero values decenter the distortion the way a shifted or tilted lens does. Leave at zero for a centered lens. + Persist + 1 + Type + Vector3 + Value + + 0.0 + 0.0 + 0.0 + + + RenderLensDistortionFit + + Comment + How the warped image is rescaled - 0 = none (pincushion can show black corners), 1 = fit (scaled so nothing ever goes black; the usual choice), 2 = fill (scaled so the whole undistorted frame stays visible, which can letterbox the corners) + Persist + 1 + Type + S32 + Value + 1 + + RenderLensDistortionK1 + + Comment + Primary radial distortion coefficient. Negative values give barrel distortion (straight lines bow outward, the wide-angle look); positive values give pincushion (lines bow inward, common on telephoto). -0.2 is a mild wide angle, -0.4 approaches a fisheye look. Has no effect until Amount is raised. Range -0.5 to 0.5. + Persist + 1 + Type + F32 + Value + -0.2 + + RenderLensDistortionK2 + + Comment + Secondary radial distortion coefficient, shaping the falloff toward the corners. Leave at 0 for a simple bend; small non-zero values reproduce the moustache distortion of real wide-angle lenses, where the bend reverses direction near the frame edge. Range -0.25 to 0.25. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderLensDistortionSqueeze + + Comment + Anamorphic desqueeze factor. 1.0 is a spherical lens and does nothing. 1.33 and 2.0 match the common anamorphic formats, stretching the image horizontally the way a desqueezed cinema frame looks. Range 0.5 to 2.5. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderLensDistortionTangential + + Comment + [-0.05, 0.05] Brown-Conrady tangential (decentering) coefficients p1 and p2 in x and y, z component unused. These model a lens element mounted slightly off-axis and shear the image rather than bending it radially. Realistic values are tiny - below 0.01. Leave at zero unless deliberately faking a misaligned lens. + Persist + 1 + Type + Vector3 + Value + + 0.0 + 0.0 + 0.0 + + RenderLensFlareChromaticSpread Comment @@ -869,7 +1292,7 @@ RenderLensFlareStarburstSpikes Comment - Number of aperture-like spikes in the starburst. Real cameras typically show 2x the blade count (6-blade = 12 spikes, etc). Range 1 to 32. + Angular frequency of the starburst rather than a literal spike count - it draws two opposed spikes per cycle, so the star carries twice this many primary spikes (the default 4 gives 8, matching an 8-blade iris) with fainter secondary rays between them. Real lenses show one spike per aperture blade when the blade count is even and two per blade when it is odd, so use half the blade count for an even iris (6 blades = 3) and the blade count itself for an odd one (7 blades = 7). Range 1 to 32. Persist 1 Type diff --git a/indra/newview/app_settings/looks/Neutral.xml b/indra/newview/app_settings/looks/Neutral.xml index 220f7f6284..2419c89a1a 100644 --- a/indra/newview/app_settings/looks/Neutral.xml +++ b/indra/newview/app_settings/looks/Neutral.xml @@ -74,7 +74,7 @@ RenderBloomScatter Comment - Bloom upsample tent-filter radius multiplier. 1.0 = adjacent-texel taps. Values >1 widen the bloom per octave; >~2.5 starts to alias because the tent skips source texels. + Bloom upsample tent-filter radius multiplier. 1.0 = adjacent-texel taps. Values >1 widen the bloom per octave; >~2.5 starts to alias because the tent skips source texels. Persist 1 Type @@ -104,6 +104,168 @@ Value 2.5 + RenderBokehAnamorphicSqueeze + + Comment + Stretches out-of-focus highlights into ovals, the way the cylindrical element of an anamorphic lens does. 1.0 is a round spherical-lens disc. Above 1 gives taller-than-wide ovals, the classic anamorphic signature; below 1 gives wider-than-tall. The stretch preserves area, so this changes the shape of the blur without changing how strong it is. Range 0.25 to 4. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderBokehApertureBlades + + Comment + Number of aperture blades shaping the out-of-focus highlights. 0 gives the perfectly round bokeh of an idealised lens. 5 to 9 reproduce real diaphragms, where the discs become visible polygons - 6 is the most common on stills lenses. Values below 3 are treated as circular. Range 0 to 11. + Persist + 1 + Type + S32 + Value + 0 + + RenderBokehApertureCurvature + + Comment + Rounds the aperture blades back toward a circle. 0 gives hard straight-edged polygons; 1 is fully round, matching a rounded diaphragm held wide open. Only has an effect when Aperture Blades is 3 or more. Range 0 to 1. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderBokehApertureRotation + + Comment + Rotation of the aperture polygon in degrees. Only has an effect when Aperture Blades is 3 or more. Range 0 to 360. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderBokehCatEyeAmount + + Comment + Optical vignetting, also called cat's eye. Toward the frame edges the lens barrel clips the aperture, squeezing round bokeh into lens-shaped slivers that lean away from the centre while the middle of the frame stays round. Strength follows distance from the centre of the image circle, so on a wide display the sides clip well before the top and bottom do. 0 disables. 0.45 is a natural fast-prime look; 1.0 is heavy. The range runs past the old maximum because measuring from the image circle lowered the offset everywhere -- a value tuned before that change wants roughly half again as much. Range 0 to 1.5. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderBokehComaAsymmetry + + Comment + Comatic asymmetry - draws each out-of-focus highlight into a comet whose tail points away from the centre of frame, the way a real lens flares off-axis. Zero on the optical axis and strongest in the corners, and it follows the highlight's actual shape, so it stays aligned with an anamorphic squeeze or a swirl rather than drifting off them. 0 disables. Pairs with RenderBokehFieldStretch, which supplies the elongation the tail runs along. Range 0 to 1. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderBokehFieldFalloff + + Comment + How quickly field aberration builds from the centre of the frame toward the corners. 1 spreads it fairly evenly across the frame; higher values keep the middle clean and concentrate the deformation at the edges. Shape control only; use RenderBokehFieldStretch to toggle the effect. Range 1 to 4. + Persist + 1 + Type + F32 + Value + 2.0 + + RenderBokehFieldStretch + + Comment + Field aberration - stretches out-of-focus highlights toward the frame edges while the centre stays round. 0 disables. Positive stretches them across the radius so they line up along circles and the frame appears to swirl, the look of old fast portrait glass; negative stretches them along the radius into comet shapes. Area-preserving, so highlights change shape without changing brightness. Range -1 to 1. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderBokehFringeAmount + + Comment + Longitudinal chromatic aberration - the colour cast real glass leaves on the rim of an out-of-focus highlight, which flips hue either side of the focal plane. 0 disables. 0.2 is a subtle uncorrected-lens feel; 0.6 is strong. This is separate from the frame-wide fringe under Chromatic Aberration, which appears whether or not anything is defocused. Range 0 to 1. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderBokehFringeFarTint + + Comment + Colour cast on highlights behind the focal plane. The default light green is the counterpart to the near tint; a real lens shifts hue in opposite directions either side of focus. + Persist + 1 + Type + Color3 + Value + + 0.85 + 1.0 + 0.9 + + + RenderBokehFringeNearTint + + Comment + Colour cast on highlights in front of the focal plane. The default light magenta matches the usual signature of uncorrected glass; swap with the far tint to reverse the effect. + Persist + 1 + Type + Color3 + Value + + 1.0 + 0.85 + 1.0 + + + RenderBokehHighlightGain + + Comment + Extra weight given to bright samples inside the defocus blur, which makes out-of-focus highlights read as distinct bokeh discs rather than a smooth blur. 0 is a plain average and is also the cheapest. 0.5 is a gentle lift; 2.0 is a strong cinematic pop. Range 0 to 4. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderBokehHighlightThreshold + + Comment + Brightness above which the highlight boost starts to apply. 0 boosts the whole range; raising it restricts the effect to genuine highlights and leaves midtones averaging normally. Only has an effect when Highlight Gain is above 0. Range 0 to 8. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderBokehSphericalAberration + + Comment + Spherical aberration - how the light inside an out-of-focus highlight is spread across it. 0 is the evenly lit disc of a perfectly corrected lens. Positive brightens the rim and hollows out the middle, the soap-bubble look; negative fills the middle and softens the edge, the creamy look. The sign flips either side of the focal plane exactly as it does in real glass, so a lens with bright-rimmed background bokeh has bright-centred foreground bokeh. Needs a few pixels of blur before there is any shape to work with, and fades out below that. Range -1 to 1. + Persist + 1 + Type + F32 + Value + 0.0 + RenderCASSharpness Comment @@ -228,7 +390,7 @@ RenderColorGradeBrightness Comment - Shifts the whole image lighter or darker. Range -0.5 to 0.5; small values (±0.05) are already noticeable. + Shifts the whole image lighter or darker. Range -0.5 to 0.5; small values (±0.05) are already noticeable. Persist 1 Type @@ -447,6 +609,72 @@ Value 1.0 + RenderCrossFilterAngle + + Comment + Rotation of the whole star in degrees, as if turning the filter in its thread. Range 0 to 360. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderCrossFilterChromatic + + Comment + Rainbow dispersion along the arms. A real star filter is a diffraction grating, and a grating separates wavelengths by angle, so the tips of the streaks go coloured while the core stays white. 0 gives clean white spokes. Range 0 to 1. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderCrossFilterFalloff + + Comment + How quickly each arm fades along its length, measured as how much brightness is lost between the core of a highlight and the tip of its arm. Low values give long even spokes; high values keep the star tight around the highlight. This changes only the shape of the arms - brightness belongs to Strength. Range 0.1 to 3. + Persist + 1 + Type + F32 + Value + 1.5 + + RenderCrossFilterLength + + Comment + Spacing of the samples that build each arm, which sets how far the star reaches. 1.0 is the tuned value and gives a clean continuous streak; higher spreads the arms further, and lower draws them in tight. The range stops at 2 because beyond that the samples building each arm stop overlapping and the streak breaks up into a repeating lattice rather than getting longer. Length and falloff work together - a long streak with a fast falloff still fades out early, which is usually what reads best. Range 0.25 to 2. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderCrossFilterPoints + + Comment + Number of arms on each star. 4 is the classic cross filter, 6 and 8 match the denser gratings. Odd counts give an asymmetric star, since the arms are spread evenly rather than in opposed pairs. Range 2 to 12. + Persist + 1 + Type + S32 + Value + 4 + + RenderCrossFilterStrength + + Comment + Strength of the cross-screen (star) filter - the etched glass filter that diffracts every bright point in frame into a star. 0 disables. The arms carry a highlight's light spread along their length, so they are much dimmer than the core and this generally wants a large value - try 8 and work outward. Unlike the starburst under Lens Flare, which is locked to the sun, this streaks every highlight. Streaks ride on the bloom pyramid, so bloom strength 0 silences them. HDR only. Range 0 to 32. + Persist + 1 + Type + F32 + Value + 0.0 + RenderDynamicExposureCoefficient Comment @@ -701,6 +929,201 @@ Value 1.3 + RenderLensDirtBloomResponse + + Comment + How strongly bloom lights up the dirt. 0 means bloom passes through a clean lens; 1 is the full response. Raise it for hazy sunlit shots where the grime should catch everything. Range 0 to 2. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderLensDirtFlareResponse + + Comment + How strongly the lens flare lights up the dirt. 0 means the flare passes through a clean lens; 1 is the full response. This is the response that reads most like a real lens, since flare and grime share the same front element. Range 0 to 2. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderLensDirtGain + + Comment + Overall brightness of the generated dirt plate, applied after the tone curve. Mostly a trim for Toe: raise it when a high Toe has crushed the plate too far. Range 0.5 to 2.5. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderLensDirtGrime + + Comment + How much muck is on the glass. Scales every layer of dust and grit at once, so this is the master density. Starting points, with Smudge and Toe to match: a lens that has been outdoors 0.45 / 0.5 / 1.8, one nobody has wiped in a while 1.0 / 1.0 / 1.6, a damaged one 0.95 / 0.9 / 1.2 with scratches, and filthy 1.9 / 1.5 / 1.2. Range 0 to 2. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderLensDirtMoteScale + + Comment + Size of the dust motes. Above 1 the specks grow and thin out, which reads as a lens that has picked up a few big flecks rather than an even film; below 1 they shrink and multiply into finer grime. Range 0.5 to 2. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderLensDirtScratches + + Comment + How many scratches cross the glass. 0 is undamaged; around 16 reads as a well-used lens with coating chips. Scratches are straight and bright where dust is soft and scattered, so a few go a long way. Range 0 to 32. + Persist + 1 + Type + S32 + Value + 0 + + RenderLensDirtSeed + + Comment + Reshuffles the grime - every value lays the dirt out differently at the same settings. It also moves the overall density by twenty or thirty percent, because dirt pools rather than spreading evenly and where it pools changes with the seed. If a plate comes out dirtier than the last, that is this and not Grime. Range 0 to 999. + Persist + 1 + Type + S32 + Value + 7 + + RenderLensDirtSmudge + + Comment + Strength of the wipe marks - the broad, soft smears a cloth leaves behind, as opposed to the discrete specks. Raise it for a lens cleaned in a hurry. Range 0 to 2. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderLensDirtStrength + + Comment + Strength of the lens dirt overlay - grime on the front element catching bloom and flare, the way a real lens lights up when something bright is in frame. 0 disables. 0.3 is a subtle used-lens feel; 1.0 is a filthy one. Only lights up where there is already bloom or flare to catch, so a flat scene stays clean no matter how high this goes. Range 0 to 2. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderLensDirtToe + + Comment + Shapes the low end of the plate - how much of the faint grime survives into the result. Lower values keep the midtones dirt actually lives in; higher values crush them and leave only the brightest specks, which reads clean no matter how high Strength goes. Range 0.6 to 4. + Persist + 1 + Type + F32 + Value + 1.6 + + RenderLensDistortionAmount + + Comment + Overall strength of the geometric lens distortion. 0 disables the effect entirely. 0.25 is a subtle wide-angle bend; 1.0 applies the shape coefficients in full. Only the world view is warped - nametags, selection outlines and UI are drawn afterwards and stay straight. Range 0 to 1. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderLensDistortionCenter + + Comment + [-0.5, 0.5] Offset of the optical axis from the frame center, x and y, z component unused. Non-zero values decenter the distortion the way a shifted or tilted lens does. Leave at zero for a centered lens. + Persist + 1 + Type + Vector3 + Value + + 0.0 + 0.0 + 0.0 + + + RenderLensDistortionFit + + Comment + How the warped image is rescaled - 0 = none (pincushion can show black corners), 1 = fit (scaled so nothing ever goes black; the usual choice), 2 = fill (scaled so the whole undistorted frame stays visible, which can letterbox the corners) + Persist + 1 + Type + S32 + Value + 1 + + RenderLensDistortionK1 + + Comment + Primary radial distortion coefficient. Negative values give barrel distortion (straight lines bow outward, the wide-angle look); positive values give pincushion (lines bow inward, common on telephoto). -0.2 is a mild wide angle, -0.4 approaches a fisheye look. Has no effect until Amount is raised. Range -0.5 to 0.5. + Persist + 1 + Type + F32 + Value + -0.2 + + RenderLensDistortionK2 + + Comment + Secondary radial distortion coefficient, shaping the falloff toward the corners. Leave at 0 for a simple bend; small non-zero values reproduce the moustache distortion of real wide-angle lenses, where the bend reverses direction near the frame edge. Range -0.25 to 0.25. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderLensDistortionSqueeze + + Comment + Anamorphic desqueeze factor. 1.0 is a spherical lens and does nothing. 1.33 and 2.0 match the common anamorphic formats, stretching the image horizontally the way a desqueezed cinema frame looks. Range 0.5 to 2.5. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderLensDistortionTangential + + Comment + [-0.05, 0.05] Brown-Conrady tangential (decentering) coefficients p1 and p2 in x and y, z component unused. These model a lens element mounted slightly off-axis and shear the image rather than bending it radially. Realistic values are tiny - below 0.01. Leave at zero unless deliberately faking a misaligned lens. + Persist + 1 + Type + Vector3 + Value + + 0.0 + 0.0 + 0.0 + + RenderLensFlareChromaticSpread Comment @@ -869,7 +1292,7 @@ RenderLensFlareStarburstSpikes Comment - Number of aperture-like spikes in the starburst. Real cameras typically show 2x the blade count (6-blade = 12 spikes, etc). Range 1 to 32. + Angular frequency of the starburst rather than a literal spike count - it draws two opposed spikes per cycle, so the star carries twice this many primary spikes (the default 4 gives 8, matching an 8-blade iris) with fainter secondary rays between them. Real lenses show one spike per aperture blade when the blade count is even and two per blade when it is odd, so use half the blade count for an even iris (6 blades = 3) and the blade count itself for an odd one (7 blades = 7). Range 1 to 32. Persist 1 Type diff --git a/indra/newview/app_settings/looks/Soft%20Film.xml b/indra/newview/app_settings/looks/Soft%20Film.xml index 58df533f4f..e4ffb4668f 100644 --- a/indra/newview/app_settings/looks/Soft%20Film.xml +++ b/indra/newview/app_settings/looks/Soft%20Film.xml @@ -74,7 +74,7 @@ RenderBloomScatter Comment - Bloom upsample tent-filter radius multiplier. 1.0 = adjacent-texel taps. Values >1 widen the bloom per octave; >~2.5 starts to alias because the tent skips source texels. + Bloom upsample tent-filter radius multiplier. 1.0 = adjacent-texel taps. Values >1 widen the bloom per octave; >~2.5 starts to alias because the tent skips source texels. Persist 1 Type @@ -104,6 +104,168 @@ Value 2.5 + RenderBokehAnamorphicSqueeze + + Comment + Stretches out-of-focus highlights into ovals, the way the cylindrical element of an anamorphic lens does. 1.0 is a round spherical-lens disc. Above 1 gives taller-than-wide ovals, the classic anamorphic signature; below 1 gives wider-than-tall. The stretch preserves area, so this changes the shape of the blur without changing how strong it is. Range 0.25 to 4. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderBokehApertureBlades + + Comment + Number of aperture blades shaping the out-of-focus highlights. 0 gives the perfectly round bokeh of an idealised lens. 5 to 9 reproduce real diaphragms, where the discs become visible polygons - 6 is the most common on stills lenses. Values below 3 are treated as circular. Range 0 to 11. + Persist + 1 + Type + S32 + Value + 0 + + RenderBokehApertureCurvature + + Comment + Rounds the aperture blades back toward a circle. 0 gives hard straight-edged polygons; 1 is fully round, matching a rounded diaphragm held wide open. Only has an effect when Aperture Blades is 3 or more. Range 0 to 1. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderBokehApertureRotation + + Comment + Rotation of the aperture polygon in degrees. Only has an effect when Aperture Blades is 3 or more. Range 0 to 360. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderBokehCatEyeAmount + + Comment + Optical vignetting, also called cat's eye. Toward the frame edges the lens barrel clips the aperture, squeezing round bokeh into lens-shaped slivers that lean away from the centre while the middle of the frame stays round. Strength follows distance from the centre of the image circle, so on a wide display the sides clip well before the top and bottom do. 0 disables. 0.45 is a natural fast-prime look; 1.0 is heavy. The range runs past the old maximum because measuring from the image circle lowered the offset everywhere -- a value tuned before that change wants roughly half again as much. Range 0 to 1.5. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderBokehComaAsymmetry + + Comment + Comatic asymmetry - draws each out-of-focus highlight into a comet whose tail points away from the centre of frame, the way a real lens flares off-axis. Zero on the optical axis and strongest in the corners, and it follows the highlight's actual shape, so it stays aligned with an anamorphic squeeze or a swirl rather than drifting off them. 0 disables. Pairs with RenderBokehFieldStretch, which supplies the elongation the tail runs along. Range 0 to 1. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderBokehFieldFalloff + + Comment + How quickly field aberration builds from the centre of the frame toward the corners. 1 spreads it fairly evenly across the frame; higher values keep the middle clean and concentrate the deformation at the edges. Shape control only; use RenderBokehFieldStretch to toggle the effect. Range 1 to 4. + Persist + 1 + Type + F32 + Value + 2.0 + + RenderBokehFieldStretch + + Comment + Field aberration - stretches out-of-focus highlights toward the frame edges while the centre stays round. 0 disables. Positive stretches them across the radius so they line up along circles and the frame appears to swirl, the look of old fast portrait glass; negative stretches them along the radius into comet shapes. Area-preserving, so highlights change shape without changing brightness. Range -1 to 1. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderBokehFringeAmount + + Comment + Longitudinal chromatic aberration - the colour cast real glass leaves on the rim of an out-of-focus highlight, which flips hue either side of the focal plane. 0 disables. 0.2 is a subtle uncorrected-lens feel; 0.6 is strong. This is separate from the frame-wide fringe under Chromatic Aberration, which appears whether or not anything is defocused. Range 0 to 1. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderBokehFringeFarTint + + Comment + Colour cast on highlights behind the focal plane. The default light green is the counterpart to the near tint; a real lens shifts hue in opposite directions either side of focus. + Persist + 1 + Type + Color3 + Value + + 0.85 + 1.0 + 0.9 + + + RenderBokehFringeNearTint + + Comment + Colour cast on highlights in front of the focal plane. The default light magenta matches the usual signature of uncorrected glass; swap with the far tint to reverse the effect. + Persist + 1 + Type + Color3 + Value + + 1.0 + 0.85 + 1.0 + + + RenderBokehHighlightGain + + Comment + Extra weight given to bright samples inside the defocus blur, which makes out-of-focus highlights read as distinct bokeh discs rather than a smooth blur. 0 is a plain average and is also the cheapest. 0.5 is a gentle lift; 2.0 is a strong cinematic pop. Range 0 to 4. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderBokehHighlightThreshold + + Comment + Brightness above which the highlight boost starts to apply. 0 boosts the whole range; raising it restricts the effect to genuine highlights and leaves midtones averaging normally. Only has an effect when Highlight Gain is above 0. Range 0 to 8. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderBokehSphericalAberration + + Comment + Spherical aberration - how the light inside an out-of-focus highlight is spread across it. 0 is the evenly lit disc of a perfectly corrected lens. Positive brightens the rim and hollows out the middle, the soap-bubble look; negative fills the middle and softens the edge, the creamy look. The sign flips either side of the focal plane exactly as it does in real glass, so a lens with bright-rimmed background bokeh has bright-centred foreground bokeh. Needs a few pixels of blur before there is any shape to work with, and fades out below that. Range -1 to 1. + Persist + 1 + Type + F32 + Value + 0.0 + RenderCASSharpness Comment @@ -228,7 +390,7 @@ RenderColorGradeBrightness Comment - Shifts the whole image lighter or darker. Range -0.5 to 0.5; small values (±0.05) are already noticeable. + Shifts the whole image lighter or darker. Range -0.5 to 0.5; small values (±0.05) are already noticeable. Persist 1 Type @@ -447,6 +609,72 @@ Value 1.0 + RenderCrossFilterAngle + + Comment + Rotation of the whole star in degrees, as if turning the filter in its thread. Range 0 to 360. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderCrossFilterChromatic + + Comment + Rainbow dispersion along the arms. A real star filter is a diffraction grating, and a grating separates wavelengths by angle, so the tips of the streaks go coloured while the core stays white. 0 gives clean white spokes. Range 0 to 1. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderCrossFilterFalloff + + Comment + How quickly each arm fades along its length, measured as how much brightness is lost between the core of a highlight and the tip of its arm. Low values give long even spokes; high values keep the star tight around the highlight. This changes only the shape of the arms - brightness belongs to Strength. Range 0.1 to 3. + Persist + 1 + Type + F32 + Value + 1.5 + + RenderCrossFilterLength + + Comment + Spacing of the samples that build each arm, which sets how far the star reaches. 1.0 is the tuned value and gives a clean continuous streak; higher spreads the arms further, and lower draws them in tight. The range stops at 2 because beyond that the samples building each arm stop overlapping and the streak breaks up into a repeating lattice rather than getting longer. Length and falloff work together - a long streak with a fast falloff still fades out early, which is usually what reads best. Range 0.25 to 2. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderCrossFilterPoints + + Comment + Number of arms on each star. 4 is the classic cross filter, 6 and 8 match the denser gratings. Odd counts give an asymmetric star, since the arms are spread evenly rather than in opposed pairs. Range 2 to 12. + Persist + 1 + Type + S32 + Value + 4 + + RenderCrossFilterStrength + + Comment + Strength of the cross-screen (star) filter - the etched glass filter that diffracts every bright point in frame into a star. 0 disables. The arms carry a highlight's light spread along their length, so they are much dimmer than the core and this generally wants a large value - try 8 and work outward. Unlike the starburst under Lens Flare, which is locked to the sun, this streaks every highlight. Streaks ride on the bloom pyramid, so bloom strength 0 silences them. HDR only. Range 0 to 32. + Persist + 1 + Type + F32 + Value + 0.0 + RenderDynamicExposureCoefficient Comment @@ -701,6 +929,201 @@ Value 1.3 + RenderLensDirtBloomResponse + + Comment + How strongly bloom lights up the dirt. 0 means bloom passes through a clean lens; 1 is the full response. Raise it for hazy sunlit shots where the grime should catch everything. Range 0 to 2. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderLensDirtFlareResponse + + Comment + How strongly the lens flare lights up the dirt. 0 means the flare passes through a clean lens; 1 is the full response. This is the response that reads most like a real lens, since flare and grime share the same front element. Range 0 to 2. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderLensDirtGain + + Comment + Overall brightness of the generated dirt plate, applied after the tone curve. Mostly a trim for Toe: raise it when a high Toe has crushed the plate too far. Range 0.5 to 2.5. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderLensDirtGrime + + Comment + How much muck is on the glass. Scales every layer of dust and grit at once, so this is the master density. Starting points, with Smudge and Toe to match: a lens that has been outdoors 0.45 / 0.5 / 1.8, one nobody has wiped in a while 1.0 / 1.0 / 1.6, a damaged one 0.95 / 0.9 / 1.2 with scratches, and filthy 1.9 / 1.5 / 1.2. Range 0 to 2. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderLensDirtMoteScale + + Comment + Size of the dust motes. Above 1 the specks grow and thin out, which reads as a lens that has picked up a few big flecks rather than an even film; below 1 they shrink and multiply into finer grime. Range 0.5 to 2. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderLensDirtScratches + + Comment + How many scratches cross the glass. 0 is undamaged; around 16 reads as a well-used lens with coating chips. Scratches are straight and bright where dust is soft and scattered, so a few go a long way. Range 0 to 32. + Persist + 1 + Type + S32 + Value + 0 + + RenderLensDirtSeed + + Comment + Reshuffles the grime - every value lays the dirt out differently at the same settings. It also moves the overall density by twenty or thirty percent, because dirt pools rather than spreading evenly and where it pools changes with the seed. If a plate comes out dirtier than the last, that is this and not Grime. Range 0 to 999. + Persist + 1 + Type + S32 + Value + 7 + + RenderLensDirtSmudge + + Comment + Strength of the wipe marks - the broad, soft smears a cloth leaves behind, as opposed to the discrete specks. Raise it for a lens cleaned in a hurry. Range 0 to 2. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderLensDirtStrength + + Comment + Strength of the lens dirt overlay - grime on the front element catching bloom and flare, the way a real lens lights up when something bright is in frame. 0 disables. 0.3 is a subtle used-lens feel; 1.0 is a filthy one. Only lights up where there is already bloom or flare to catch, so a flat scene stays clean no matter how high this goes. Range 0 to 2. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderLensDirtToe + + Comment + Shapes the low end of the plate - how much of the faint grime survives into the result. Lower values keep the midtones dirt actually lives in; higher values crush them and leave only the brightest specks, which reads clean no matter how high Strength goes. Range 0.6 to 4. + Persist + 1 + Type + F32 + Value + 1.6 + + RenderLensDistortionAmount + + Comment + Overall strength of the geometric lens distortion. 0 disables the effect entirely. 0.25 is a subtle wide-angle bend; 1.0 applies the shape coefficients in full. Only the world view is warped - nametags, selection outlines and UI are drawn afterwards and stay straight. Range 0 to 1. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderLensDistortionCenter + + Comment + [-0.5, 0.5] Offset of the optical axis from the frame center, x and y, z component unused. Non-zero values decenter the distortion the way a shifted or tilted lens does. Leave at zero for a centered lens. + Persist + 1 + Type + Vector3 + Value + + 0.0 + 0.0 + 0.0 + + + RenderLensDistortionFit + + Comment + How the warped image is rescaled - 0 = none (pincushion can show black corners), 1 = fit (scaled so nothing ever goes black; the usual choice), 2 = fill (scaled so the whole undistorted frame stays visible, which can letterbox the corners) + Persist + 1 + Type + S32 + Value + 1 + + RenderLensDistortionK1 + + Comment + Primary radial distortion coefficient. Negative values give barrel distortion (straight lines bow outward, the wide-angle look); positive values give pincushion (lines bow inward, common on telephoto). -0.2 is a mild wide angle, -0.4 approaches a fisheye look. Has no effect until Amount is raised. Range -0.5 to 0.5. + Persist + 1 + Type + F32 + Value + -0.2 + + RenderLensDistortionK2 + + Comment + Secondary radial distortion coefficient, shaping the falloff toward the corners. Leave at 0 for a simple bend; small non-zero values reproduce the moustache distortion of real wide-angle lenses, where the bend reverses direction near the frame edge. Range -0.25 to 0.25. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderLensDistortionSqueeze + + Comment + Anamorphic desqueeze factor. 1.0 is a spherical lens and does nothing. 1.33 and 2.0 match the common anamorphic formats, stretching the image horizontally the way a desqueezed cinema frame looks. Range 0.5 to 2.5. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderLensDistortionTangential + + Comment + [-0.05, 0.05] Brown-Conrady tangential (decentering) coefficients p1 and p2 in x and y, z component unused. These model a lens element mounted slightly off-axis and shear the image rather than bending it radially. Realistic values are tiny - below 0.01. Leave at zero unless deliberately faking a misaligned lens. + Persist + 1 + Type + Vector3 + Value + + 0.0 + 0.0 + 0.0 + + RenderLensFlareChromaticSpread Comment @@ -869,7 +1292,7 @@ RenderLensFlareStarburstSpikes Comment - Number of aperture-like spikes in the starburst. Real cameras typically show 2x the blade count (6-blade = 12 spikes, etc). Range 1 to 32. + Angular frequency of the starburst rather than a literal spike count - it draws two opposed spikes per cycle, so the star carries twice this many primary spikes (the default 4 gives 8, matching an 8-blade iris) with fainter secondary rays between them. Real lenses show one spike per aperture blade when the blade count is even and two per blade when it is odd, so use half the blade count for an even iris (6 blades = 3) and the blade count itself for an odd one (7 blades = 7). Range 1 to 32. Persist 1 Type diff --git a/indra/newview/app_settings/settings_alchemy.xml b/indra/newview/app_settings/settings_alchemy.xml index 939b0f016b..7f5e171371 100644 --- a/indra/newview/app_settings/settings_alchemy.xml +++ b/indra/newview/app_settings/settings_alchemy.xml @@ -1866,6 +1866,267 @@ Value 0.0 + RenderCrossFilterStrength + + Comment + Strength of the cross-screen (star) filter - the etched glass filter that diffracts every bright point in frame into a star. 0 disables. The arms carry a highlight's light spread along their length, so they are much dimmer than the core and this generally wants a large value - try 8 and work outward. Unlike the starburst under Lens Flare, which is locked to the sun, this streaks every highlight. Streaks ride on the bloom pyramid, so bloom strength 0 silences them. HDR only. Range 0 to 32. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderCrossFilterPoints + + Comment + Number of arms on each star. 4 is the classic cross filter, 6 and 8 match the denser gratings. Odd counts give an asymmetric star, since the arms are spread evenly rather than in opposed pairs. Range 2 to 12. + Persist + 1 + Type + S32 + Value + 4 + + RenderCrossFilterAngle + + Comment + Rotation of the whole star in degrees, as if turning the filter in its thread. Range 0 to 360. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderCrossFilterLength + + Comment + Spacing of the samples that build each arm, which sets how far the star reaches. 1.0 is the tuned value and gives a clean continuous streak; higher spreads the arms further, and lower draws them in tight. The range stops at 2 because beyond that the samples building each arm stop overlapping and the streak breaks up into a repeating lattice rather than getting longer. Length and falloff work together - a long streak with a fast falloff still fades out early, which is usually what reads best. Range 0.25 to 2. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderCrossFilterFalloff + + Comment + How quickly each arm fades along its length, measured as how much brightness is lost between the core of a highlight and the tip of its arm. Low values give long even spokes; high values keep the star tight around the highlight. This changes only the shape of the arms - brightness belongs to Strength. Range 0.1 to 3. + Persist + 1 + Type + F32 + Value + 1.5 + + RenderCrossFilterChromatic + + Comment + Rainbow dispersion along the arms. A real star filter is a diffraction grating, and a grating separates wavelengths by angle, so the tips of the streaks go coloured while the core stays white. 0 gives clean white spokes. Range 0 to 1. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderLensDirtStrength + + Comment + Strength of the lens dirt overlay - grime on the front element catching bloom and flare, the way a real lens lights up when something bright is in frame. 0 disables. 0.3 is a subtle used-lens feel; 1.0 is a filthy one. Only lights up where there is already bloom or flare to catch, so a flat scene stays clean no matter how high this goes. Range 0 to 2. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderLensDirtBloomResponse + + Comment + How strongly bloom lights up the dirt. 0 means bloom passes through a clean lens; 1 is the full response. Raise it for hazy sunlit shots where the grime should catch everything. Range 0 to 2. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderLensDirtFlareResponse + + Comment + How strongly the lens flare lights up the dirt. 0 means the flare passes through a clean lens; 1 is the full response. This is the response that reads most like a real lens, since flare and grime share the same front element. Range 0 to 2. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderLensDirtGain + + Comment + Overall brightness of the generated dirt plate, applied after the tone curve. Mostly a trim for Toe: raise it when a high Toe has crushed the plate too far. Range 0.5 to 2.5. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderLensDirtGrime + + Comment + How much muck is on the glass. Scales every layer of dust and grit at once, so this is the master density. Starting points, with Smudge and Toe to match: a lens that has been outdoors 0.45 / 0.5 / 1.8, one nobody has wiped in a while 1.0 / 1.0 / 1.6, a damaged one 0.95 / 0.9 / 1.2 with scratches, and filthy 1.9 / 1.5 / 1.2. Range 0 to 2. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderLensDirtMoteScale + + Comment + Size of the dust motes. Above 1 the specks grow and thin out, which reads as a lens that has picked up a few big flecks rather than an even film; below 1 they shrink and multiply into finer grime. Range 0.5 to 2. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderLensDirtScratches + + Comment + How many scratches cross the glass. 0 is undamaged; around 16 reads as a well-used lens with coating chips. Scratches are straight and bright where dust is soft and scattered, so a few go a long way. Range 0 to 32. + Persist + 1 + Type + S32 + Value + 0 + + RenderLensDirtSeed + + Comment + Reshuffles the grime - every value lays the dirt out differently at the same settings. It also moves the overall density by twenty or thirty percent, because dirt pools rather than spreading evenly and where it pools changes with the seed. If a plate comes out dirtier than the last, that is this and not Grime. Range 0 to 999. + Persist + 1 + Type + S32 + Value + 7 + + RenderLensDirtSmudge + + Comment + Strength of the wipe marks - the broad, soft smears a cloth leaves behind, as opposed to the discrete specks. Raise it for a lens cleaned in a hurry. Range 0 to 2. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderLensDirtToe + + Comment + Shapes the low end of the plate - how much of the faint grime survives into the result. Lower values keep the midtones dirt actually lives in; higher values crush them and leave only the brightest specks, which reads clean no matter how high Strength goes. Range 0.6 to 4. + Persist + 1 + Type + F32 + Value + 1.6 + + RenderLensDistortionAmount + + Comment + Overall strength of the geometric lens distortion. 0 disables the effect entirely. 0.25 is a subtle wide-angle bend; 1.0 applies the shape coefficients in full. Only the world view is warped - nametags, selection outlines and UI are drawn afterwards and stay straight. Range 0 to 1. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderLensDistortionK1 + + Comment + Primary radial distortion coefficient. Negative values give barrel distortion (straight lines bow outward, the wide-angle look); positive values give pincushion (lines bow inward, common on telephoto). -0.2 is a mild wide angle, -0.4 approaches a fisheye look. Has no effect until Amount is raised. Range -0.5 to 0.5. + Persist + 1 + Type + F32 + Value + -0.2 + + RenderLensDistortionK2 + + Comment + Secondary radial distortion coefficient, shaping the falloff toward the corners. Leave at 0 for a simple bend; small non-zero values reproduce the moustache distortion of real wide-angle lenses, where the bend reverses direction near the frame edge. Range -0.25 to 0.25. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderLensDistortionSqueeze + + Comment + Anamorphic desqueeze factor. 1.0 is a spherical lens and does nothing. 1.33 and 2.0 match the common anamorphic formats, stretching the image horizontally the way a desqueezed cinema frame looks. Range 0.5 to 2.5. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderLensDistortionFit + + Comment + How the warped image is rescaled - 0 = none (pincushion can show black corners), 1 = fit (scaled so nothing ever goes black; the usual choice), 2 = fill (scaled so the whole undistorted frame stays visible, which can letterbox the corners) + Persist + 1 + Type + S32 + Value + 1 + + RenderLensDistortionCenter + + Comment + [-0.5, 0.5] Offset of the optical axis from the frame center, x and y, z component unused. Non-zero values decenter the distortion the way a shifted or tilted lens does. Leave at zero for a centered lens. + Persist + 1 + Type + Vector3 + Value + + 0.0 + 0.0 + 0.0 + + + RenderLensDistortionTangential + + Comment + [-0.05, 0.05] Brown-Conrady tangential (decentering) coefficients p1 and p2 in x and y, z component unused. These model a lens element mounted slightly off-axis and shear the image rather than bending it radially. Realistic values are tiny - below 0.01. Leave at zero unless deliberately faking a misaligned lens. + Persist + 1 + Type + Vector3 + Value + + 0.0 + 0.0 + 0.0 + + RenderColorGrade Comment @@ -2562,7 +2823,7 @@ RenderLensFlareStarburstSpikes Comment - Number of aperture-like spikes in the starburst. Real cameras typically show 2x the blade count (6-blade = 12 spikes, etc). Range 1 to 32. + Angular frequency of the starburst rather than a literal spike count - it draws two opposed spikes per cycle, so the star carries twice this many primary spikes (the default 4 gives 8, matching an 8-blade iris) with fainter secondary rays between them. Real lenses show one spike per aperture blade when the blade count is even and two per blade when it is odd, so use half the blade count for an even iris (6 blades = 3) and the blade count itself for an odd one (7 blades = 7). Range 1 to 32. Persist 1 Type @@ -2970,6 +3231,179 @@ Value 0 + RenderBokehHighlightGain + + Comment + Extra weight given to bright samples inside the defocus blur, which makes out-of-focus highlights read as distinct bokeh discs rather than a smooth blur. 0 is a plain average and is also the cheapest. 0.5 is a gentle lift; 2.0 is a strong cinematic pop. Range 0 to 4. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderBokehHighlightThreshold + + Comment + Brightness above which the highlight boost starts to apply. 0 boosts the whole range; raising it restricts the effect to genuine highlights and leaves midtones averaging normally. Only has an effect when Highlight Gain is above 0. Range 0 to 8. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderBokehHighlightClamp + + Comment + Ceiling on how much radiance a single sample may contribute to a neighbouring bokeh disc. A specular glint can carry thousands of times the brightness of its surroundings; uncapped it spreads into a large blob that flickers as the camera moves. The in-focus pixel keeps its full intensity either way - only its contribution to the blur is capped. 0 disables the cap. Range 0 to 1024. + Persist + 1 + Type + F32 + Value + 64.0 + + RenderBokehApertureBlades + + Comment + Number of aperture blades shaping the out-of-focus highlights. 0 gives the perfectly round bokeh of an idealised lens. 5 to 9 reproduce real diaphragms, where the discs become visible polygons - 6 is the most common on stills lenses. Values below 3 are treated as circular. Range 0 to 11. + Persist + 1 + Type + S32 + Value + 0 + + RenderBokehApertureRotation + + Comment + Rotation of the aperture polygon in degrees. Only has an effect when Aperture Blades is 3 or more. Range 0 to 360. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderBokehApertureCurvature + + Comment + Rounds the aperture blades back toward a circle. 0 gives hard straight-edged polygons; 1 is fully round, matching a rounded diaphragm held wide open. Only has an effect when Aperture Blades is 3 or more. Range 0 to 1. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderBokehAnamorphicSqueeze + + Comment + Stretches out-of-focus highlights into ovals, the way the cylindrical element of an anamorphic lens does. 1.0 is a round spherical-lens disc. Above 1 gives taller-than-wide ovals, the classic anamorphic signature; below 1 gives wider-than-tall. The stretch preserves area, so this changes the shape of the blur without changing how strong it is. Range 0.25 to 4. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderBokehCatEyeAmount + + Comment + Optical vignetting, also called cat's eye. Toward the frame edges the lens barrel clips the aperture, squeezing round bokeh into lens-shaped slivers that lean away from the centre while the middle of the frame stays round. Strength follows distance from the centre of the image circle, so on a wide display the sides clip well before the top and bottom do. 0 disables. 0.45 is a natural fast-prime look; 1.0 is heavy. The range runs past the old maximum because measuring from the image circle lowered the offset everywhere -- a value tuned before that change wants roughly half again as much. Range 0 to 1.5. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderBokehFringeAmount + + Comment + Longitudinal chromatic aberration - the colour cast real glass leaves on the rim of an out-of-focus highlight, which flips hue either side of the focal plane. 0 disables. 0.2 is a subtle uncorrected-lens feel; 0.6 is strong. This is separate from the frame-wide fringe under Chromatic Aberration, which appears whether or not anything is defocused. Range 0 to 1. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderBokehFringeNearTint + + Comment + Colour cast on highlights in front of the focal plane. The default light magenta matches the usual signature of uncorrected glass; swap with the far tint to reverse the effect. + Persist + 1 + Type + Color3 + Value + + 1.0 + 0.85 + 1.0 + + + RenderBokehFringeFarTint + + Comment + Colour cast on highlights behind the focal plane. The default light green is the counterpart to the near tint; a real lens shifts hue in opposite directions either side of focus. + Persist + 1 + Type + Color3 + Value + + 0.85 + 1.0 + 0.9 + + + RenderBokehSphericalAberration + + Comment + Spherical aberration - how the light inside an out-of-focus highlight is spread across it. 0 is the evenly lit disc of a perfectly corrected lens. Positive brightens the rim and hollows out the middle, the soap-bubble look; negative fills the middle and softens the edge, the creamy look. The sign flips either side of the focal plane exactly as it does in real glass, so a lens with bright-rimmed background bokeh has bright-centred foreground bokeh. Needs a few pixels of blur before there is any shape to work with, and fades out below that. Range -1 to 1. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderBokehFieldStretch + + Comment + Field aberration - stretches out-of-focus highlights toward the frame edges while the centre stays round. 0 disables. Positive stretches them across the radius so they line up along circles and the frame appears to swirl, the look of old fast portrait glass; negative stretches them along the radius into comet shapes. Area-preserving, so highlights change shape without changing brightness. Range -1 to 1. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderBokehFieldFalloff + + Comment + How quickly field aberration builds from the centre of the frame toward the corners. 1 spreads it fairly evenly across the frame; higher values keep the middle clean and concentrate the deformation at the edges. Shape control only; use RenderBokehFieldStretch to toggle the effect. Range 1 to 4. + Persist + 1 + Type + F32 + Value + 2.0 + + RenderBokehComaAsymmetry + + Comment + Comatic asymmetry - draws each out-of-focus highlight into a comet whose tail points away from the centre of frame, the way a real lens flares off-axis. Zero on the optical axis and strongest in the corners, and it follows the highlight's actual shape, so it stays aligned with an anamorphic squeeze or a swirl rather than drifting off them. 0 disables. Pairs with RenderBokehFieldStretch, which supplies the elongation the tail runs along. Range 0 to 1. + Persist + 1 + Type + F32 + Value + 0.0 + AllowNoCopyRezRestoreToWorld Comment diff --git a/indra/newview/app_settings/shaders/class1/alchemy/blitWithEffectsF.glsl b/indra/newview/app_settings/shaders/class1/alchemy/blitWithEffectsF.glsl index d3f7c630f2..6848c7be1d 100644 --- a/indra/newview/app_settings/shaders/class1/alchemy/blitWithEffectsF.glsl +++ b/indra/newview/app_settings/shaders/class1/alchemy/blitWithEffectsF.glsl @@ -61,6 +61,8 @@ uniform float uRefWipePos; // Seam position, 0..1 across the frame. vec3 clampHDRRange(vec3 color); // From postEffectUtilsF.glsl (auto-linked via mFeatures.hasPostEffects). +vec2 applyLensDistortion(vec2 uv); +float lensDistortMask(vec2 duv); vec3 applyVignette(vec3 color, vec2 uv); vec3 applyCVDCompensation(vec3 color); vec3 applyFilmGrain(vec3 color, vec2 fragCoord); @@ -79,25 +81,43 @@ vec3 applyPreview(vec3 color); // treatment, which is what a still is for. // // uRefWipeMode is zero whenever there is no still, so the sampler is never -// read unless one has been grabbed. +// read unless one has been grabbed -- GLSL's ?: evaluates only the selected +// branch, so the ternary below preserves that. +// +// Lens distortion is threaded through here rather than applied to +// vary_fragcoord in main(), and the order within this function matters: +// remap first (which pane, which side of the seam), distort second. That way +// the still and the live frame carry an identical warp, so a wipe compares +// two grades of the same lens rather than a lens against a flat plate. The +// seam test itself keeps the *raw* uv, which is what holds the divider +// straight while the image bends around it. vec4 sampleWithReference(vec2 uv) { + vec2 pane_uv = uv; + bool from_still = false; + if (uRefWipeMode == 1) { // Wipe: the still to the left of the seam, live to the right. - return (uv.x < uRefWipePos) ? texture(uReferenceStill, uv) - : texture(diffuseRect, uv); + from_still = (uv.x < uRefWipePos); } - - if (uRefWipeMode == 2) + else if (uRefWipeMode == 2) { // Side by side: both squeezed two to one, so the same region of the // image appears twice rather than two different halves of it. - return (uv.x < 0.5) ? texture(uReferenceStill, vec2(uv.x * 2.0, uv.y)) - : texture(diffuseRect, vec2((uv.x - 0.5) * 2.0, uv.y)); + from_still = (uv.x < 0.5); + pane_uv.x = from_still ? uv.x * 2.0 : (uv.x - 0.5) * 2.0; } - return texture(diffuseRect, uv); + vec2 duv = applyLensDistortion(pane_uv); + vec4 s = from_still ? texture(uReferenceStill, duv) + : texture(diffuseRect, duv); + + // Resolve out-of-frame samples to black rather than smearing the edge + // texel into the corners. rgb only: alpha carries no meaning at the + // backbuffer, and masking it would be a silent behaviour change. + s.rgb *= lensDistortMask(duv); + return s; } // A hairline at the seam, so the eye knows which side it is looking at. Drawn diff --git a/indra/newview/app_settings/shaders/class1/alchemy/colorCorrectF.glsl b/indra/newview/app_settings/shaders/class1/alchemy/colorCorrectF.glsl index 2711b767e5..3b54cb4973 100644 --- a/indra/newview/app_settings/shaders/class1/alchemy/colorCorrectF.glsl +++ b/indra/newview/app_settings/shaders/class1/alchemy/colorCorrectF.glsl @@ -47,6 +47,8 @@ uniform sampler2D depthMap; // halation signal rides in the alpha channel; otherwise the pyramid is RGB-only. uniform sampler2D bloomMap; uniform float bloom_strength; +uniform sampler2D crossFilterMap; // streak accumulator; uCrossStrength gates it +uniform float uCrossStrength; // 0 when the filter is off or unbound #ifdef BLOOM_HALATION uniform float halation_strength; uniform vec3 halation_tint; @@ -82,8 +84,18 @@ vec3 applyChannelCurves(vec3 diff); #ifdef HAS_POST_EFFECTS vec3 computeLensFlare(sampler2D diffuse, sampler2D depth, vec2 uv); vec4 applyChromaticAberration(sampler2D tex, vec2 uv); +vec3 applyLensDirt(vec2 uv, vec3 lens_light); #endif +// How much each source lights the grime. Only this shader composites the +// terms they scale, but the declarations deliberately sit OUTSIDE the +// HAS_POST_EFFECTS guard: uLensDirtBloomResponse is consumed in the +// independently-guarded BLOOM_COMPOSITE block, and tying the declaration to +// the other define made any future BLOOM_COMPOSITE-without-HAS_POST_EFFECTS +// program a hard compile error. An unused uniform costs nothing. +uniform float uLensDirtBloomResponse; +uniform float uLensDirtFlareResponse; + #ifdef DITHER vec3 applyDither(vec3 color, vec2 fragCoord); #endif @@ -108,9 +120,19 @@ void main() { // === LINEAR SPACE ======================================================== + // Light falling on the front element, accumulated as it is composited. + // Lens dirt is only visible where something is already glowing, so it needs + // the flare and bloom terms themselves rather than the finished image -- + // hence capturing them here instead of adding them anonymously. + vec3 lens_light = vec3(0.0); + #ifdef HAS_POST_EFFECTS vec4 diff = applyChromaticAberration(diffuseRect, vary_fragcoord); - diff.rgb += computeLensFlare(diffuseRect, depthMap, vary_fragcoord); + { + vec3 flare = computeLensFlare(diffuseRect, depthMap, vary_fragcoord); + diff.rgb += flare; + lens_light += flare * uLensDirtFlareResponse; + } #else vec4 diff = texture(diffuseRect, vary_fragcoord); #endif @@ -121,14 +143,34 @@ void main() { vec4 bloom_sample = texture(bloomMap, vary_fragcoord); #ifdef BLOOM_HALATION - diff.rgb += bloom_sample.rgb * bloom_strength - + bloom_sample.a * halation_strength * halation_tint; + vec3 bloom_term = bloom_sample.rgb * bloom_strength + + bloom_sample.a * halation_strength * halation_tint; #else - diff.rgb += bloom_sample.rgb * bloom_strength; + vec3 bloom_term = bloom_sample.rgb * bloom_strength; #endif + // Cross-filter streaks, composited here instead of in a pass of their + // own. Added to bloom_term rather than to diff so they inherit both of + // the couplings they had while they lived inside the pyramid: bloom + // strength scales them, and they light the lens dirt below. + if (uCrossStrength > 0.0) + { + bloom_term += texture(crossFilterMap, vary_fragcoord).rgb + * uCrossStrength * bloom_strength; + } + + diff.rgb += bloom_term; + lens_light += bloom_term * uLensDirtBloomResponse; } #endif +#ifdef HAS_POST_EFFECTS + // Still in linear light, so the dirt is exposed and tonemapped along with + // the light that lit it. In the non-HDR path lens_light carries the flare + // alone -- legacy glow composites in a separate pass much later, which is + // out of reach from here. + diff.rgb += applyLensDirt(vary_fragcoord, lens_light); +#endif + #ifdef TONEMAP diff.rgb = applyExposure(diff.rgb); #endif diff --git a/indra/newview/app_settings/shaders/class1/alchemy/postEffectUtilsF.glsl b/indra/newview/app_settings/shaders/class1/alchemy/postEffectUtilsF.glsl index b3ddda674a..0350ab6c25 100644 --- a/indra/newview/app_settings/shaders/class1/alchemy/postEffectUtilsF.glsl +++ b/indra/newview/app_settings/shaders/class1/alchemy/postEffectUtilsF.glsl @@ -18,12 +18,19 @@ * vec3 computeLensFlare (sampler2D diff, sampler2D depth, vec2 uv) * * DISPLAY SPACE (blitWithEffectsF) + * vec2 applyLensDistortion (vec2 uv) -- UV in, UV out * vec3 applyVignette (vec3 color, vec2 uv) * vec3 applyCVDCompensation (vec3 color) * vec3 applyFilmGrain (vec3 color, vec2 fragCoord) * vec3 applyDither (vec3 color, vec2 fragCoord) * vec3 applyPreview (vec3 color) * + * applyLensDistortion is the odd one out: it transforms a sample coordinate + * rather than a colour, so it runs *before* the scene is sampled and the + * colour effects above run on the result. It is a lens effect; everything + * else in the DISPLAY SPACE group is a sensor or print effect and stays in + * unwarped screen space. + * * Conventions used throughout: * - Every effect has an `amount <= 0` fast-path that returns the input * unchanged, so the call site can unconditionally chain them. @@ -813,3 +820,132 @@ vec3 applyDither(vec3 color, vec2 fragCoord) float levels = (uDitherBits >= 10) ? 1023.0 : 255.0; return color + tpdf * (uDitherAmount / levels); } + + +// ============================================================================= +// Geometric lens distortion — Brown-Conrady radial + tangential +// ============================================================================= +// +// The only entry point here that transforms a coordinate instead of a colour. +// Runs in the final blit, warping the coordinate the scene is sampled at, so +// the vignette/grain/dither/CVD chain that follows stays in unwarped sensor +// space. That split is deliberate: distortion happens in the lens, the print +// effects happen at the sensor and on the print. +// +// This is the *inverse* map — for each output pixel it answers "where in the +// source does this come from", which is the direction a gather-based post +// process needs. Negative k1 therefore reads as barrel and positive as +// pincushion, matching how the coefficients are named on a real lens profile. +// +// Radial distance is measured in aspect-corrected units using the same +// branchless form the rest of this file uses: one component of `scale` stays +// 1.0 and the other carries the ratio, so the wide axis is stretched to its +// true physical extent and r lands near 1.0 at the frame corners. That is not +// cosmetic -- on a 16:9 frame the corners really are further from the optical +// axis than the edge midpoints, and a lens distorts by physical radius. +// +// Note: uLensDistortAmount, uLensDistortK, uLensDistortScale, uLensDistortSqueeze +// and uLensDistortTangential arrive pre-baked from the CPU (pipeline.cpp). +// The master amount is folded into the coefficients there, so the shader does +// one gate and then pure polynomial evaluation. The slider ranges quoted below +// are the *user-facing* values before baking. + +uniform float uLensDistortAmount; // artist range [0, 1]: master gate. 0 disables. +uniform vec2 uLensDistortK; // (k1, k2) already multiplied by amount on the CPU. + // k1 artist range [-0.5, 0.5]: negative barrel, + // positive pincushion. k2 [-0.25, 0.25] shapes the tail. +uniform float uLensDistortScale; // auto-fit rescale, applied as a direct multiplier. Solved + // exactly on the CPU over a dense frame probe (boundary + // walk, plus an interior grid in Fit mode). + // Uploaded as 1.0 when fit is off -- never 0. +uniform vec2 uLensDistortSqueeze; // (1 / squeeze, 1), pre-reciprocated. Anamorphic desqueeze. +uniform vec2 uLensDistortCenter; // [-0.5, 0.5] optical axis offset from frame centre. +uniform vec2 uLensDistortTangential; // (p1, p2) already multiplied by amount on the CPU. + // Decentering terms; tiny values (< 0.01) are realistic. + +vec2 applyLensDistortion(vec2 uv) +{ + // Fast path when the effect is disabled — the uniform branch is coherent + // across the whole draw, and zero-initialized uniforms land here (see the + // GL 4.1 no-default-initializers note in llshadermgr). + if (uLensDistortAmount <= 0.0) + return uv; + + // Offset from the optical axis, then aspect-corrected so the radial term + // is isotropic in physical units rather than in UV units. + vec2 p = uv - 0.5 - uLensDistortCenter; + float aspect = uResolution.x / max(uResolution.y, 1.0); + vec2 scale = max(vec2(aspect, 1.0 / max(aspect, 1e-4)), 1.0); + vec2 q = p * scale; + + float r2 = dot(q, q); + + // Radial: 1 + k1*r^2 + k2*r^4. Horner keeps it to two FMAs. + float radial = 1.0 + r2 * (uLensDistortK.x + r2 * uLensDistortK.y); + + // Tangential: the classic Brown-Conrady decentering pair. Zero by default, + // and the two terms vanish independently, so leaving them at 0 costs only + // the multiplies. + float p1 = uLensDistortTangential.x; + float p2 = uLensDistortTangential.y; + vec2 tangential = vec2(2.0 * p1 * q.x * q.y + p2 * (r2 + 2.0 * q.x * q.x), + p1 * (r2 + 2.0 * q.y * q.y) + 2.0 * p2 * q.x * q.y); + + // Warp, rescale to keep the frame filled, undo the aspect correction, then + // apply the anamorphic squeeze in UV space. + vec2 warped = (q * radial + tangential) * uLensDistortScale; + warped /= scale; + warped *= uLensDistortSqueeze; + + return warped + 0.5 + uLensDistortCenter; +} + +// ============================================================================= +// Lens dirt — grime on the front element, lit by whatever is already glowing +// ============================================================================= +// +// Contributes nothing on its own. Dirt is only visible where light is already +// falling on it, so this takes the bloom and flare terms as its input rather +// than the scene: point the camera at a flat wall and the lens looks clean no +// matter how high the strength goes, exactly as a real one does. It is also +// what makes the effect cheap -- the cost rides on effects that are already +// running. +// +// Tier 3 (uniform branch) rather than a compile-time permutation, because this +// file is a shared object attached to all nine post programs and no +// per-program define can reach it. The strength is forced to 0 by the CPU +// whenever no plate is loaded, so the sampler is never read unbound. +// +// Single-channel plates are swizzled R -> RGB at upload, so mono and colour +// plates both arrive here as plain RGB. + +uniform sampler2D uLensDirtMap; +uniform float uLensDirtStrength; // 0 disables + +vec3 applyLensDirt(vec2 uv, vec3 lens_light) +{ + if (uLensDirtStrength <= 0.0) + return vec3(0.0); + + // Sampled with raw screen UV, and there is no fitting to do: the plate is + // generated at the frame's own aspect, so a mote is already round on the + // display it was made for. The square plates this replaced needed a + // cover-fit and gave up the frame's edges to get it. + // + // Single channel -- dirt is a scalar mask, and the generator writes one. + return lens_light * texture(uLensDirtMap, uv).r * uLensDirtStrength; +} + + +// Companion to the above: 1.0 inside the frame, 0.0 outside, so the call site +// can resolve out-of-frame samples to black instead of smearing the edge texel +// across the corners. Branchless, and identically 1.0 when distortion is off +// (the early-out above returns an in-range uv, and the gate makes that exact). +float lensDistortMask(vec2 duv) +{ + if (uLensDistortAmount <= 0.0) + return 1.0; + + vec2 inside = step(vec2(0.0), duv) * step(duv, vec2(1.0)); + return inside.x * inside.y; +} diff --git a/indra/newview/app_settings/shaders/class1/deferred/postDeferredF.glsl b/indra/newview/app_settings/shaders/class1/deferred/postDeferredF.glsl index 4d5615af0e..d8a5dc4f9f 100644 --- a/indra/newview/app_settings/shaders/class1/deferred/postDeferredF.glsl +++ b/indra/newview/app_settings/shaders/class1/deferred/postDeferredF.glsl @@ -34,43 +34,238 @@ uniform sampler2D diffuseRect; //[ENGINE_BLOCK Matrices] uniform vec2 screen_res; uniform float max_cof; -uniform float res_scale; in vec2 vary_fragcoord; -void dofSample(inout vec4 diff, inout float w, float min_sc, vec2 tc) +// ============================================================================= +// Bokeh gather weighting +// ============================================================================= +// +// This pass moved ahead of the tonemapper, which changes what the sample +// weighting has to do. The old form was `wg = 0.25 + s.r+s.g+s.b`, applied to +// display-space values already compressed into [0, 1]: there the largest a +// sample could weigh was 3.25x the smallest, a mild nudge that made highlights +// read against a tonemapper that had already crushed them. +// +// On linear HDR the same expression is an accidental max filter. A punctual +// specular peak has no solid angle, so one pixel can carry five figures of +// radiance -- weighted by `r+g+b` it outweighs every other sample in its disc +// combined, and the disc becomes a flat plate of that one colour. So the pop +// that expression was faking now has to be asked for explicitly, and the +// default is a plain energy-conserving average. + +uniform float uBokehHighlightThreshold; // luma where the boost starts; 0 boosts the whole range +uniform float uBokehHighlightGain; // 0 = plain average (fast path); higher = more highlight pop +uniform float uBokehHighlightClamp; // per-sample radiance ceiling; <= 0 disables + +// Per-sample radiance ceiling -- the DoF-side analogue of the firefly clamp +// bloomExtractF applies for the same reason. Bounding the artifact, not the +// image: the in-focus pixel keeps its full intensity, only what a sample is +// allowed to contribute to a *neighbour's* disc is capped. Scaled by the +// largest channel so a clamped highlight keeps its colour instead of sliding +// toward whichever primary saturated first. +vec3 bokehClamp(vec3 c) { - vec4 s = texture(diffuseRect, tc); + if (uBokehHighlightClamp <= 0.0) + return c; - float sc = abs(s.a*2.0-1.0)*max_cof; + float peak = max(max(c.r, c.g), c.b); + return (peak > uBokehHighlightClamp) ? c * (uBokehHighlightClamp / peak) : c; +} - if (sc > min_sc) //sampled pixel is more "out of focus" than current sample radius +// Energy-conserving by default: every accepted sample counts once, so the +// gather is an average and a bright sample contributes its brightness rather +// than extra influence. +float bokehWeight(vec3 c) +{ + if (uBokehHighlightGain <= 0.0) + return 1.0; + + float peak = max(max(c.r, c.g), c.b); + return 1.0 + uBokehHighlightGain * max(peak - uBokehHighlightThreshold, 0.0); +} + +#if DOF_SHAPED +// ============================================================================= +// Shaped aperture, optical vignetting, and defocus fringing +// ============================================================================= +// +// Everything in this block is compiled out entirely unless at least one of +// these effects is switched on, because it lives in the innermost sample loop +// where a uniform branch still costs registers. The CPU picks between the +// shaped and unshaped programs; within a shaped one each effect gates on its +// own uniform, the way the lens flare's sub-effects do. The CPU-side `shaped` +// predicate in pipeline.cpp must list every effect that lives here -- one it +// misses becomes a dead control whenever it is the only one active. + +uniform int uBokehBlades; // 0 = circular; 3..11 = polygon +uniform float uBokehApertureRotation; // radians, converted from degrees on the CPU +uniform float uBokehApertureCurvature; // 0 straight blades -> 1 fully round +uniform vec3 uBokehApertureConst; // (pi/N, 2pi/N, cos(pi/N)) baked on the CPU +uniform vec2 uBokehAnamorphic; // per-axis sample stretch, area-preserving; (1,1) = spherical +uniform float uBokehCatEye; // 0 disables; higher clips harder toward the edges +uniform float uBokehFringeAmount; // 0 disables +uniform vec3 uBokehFringeNearTint; // applied in front of the focal plane +uniform vec3 uBokehFringeFarTint; // applied behind it +uniform float uBokehSpherical; // -1 creamy .. 0 flat disc .. +1 soap bubble +uniform float uBokehFieldStretch; // 0 disables; + tangential (swirl), - radial (coma) +uniform float uBokehFieldFalloff; // how fast the stretch grows toward the corners +uniform float uBokehComaAsymmetry; // 0 disables; ramps with field radius + +// The floor keeps the shape weight strictly positive. Two reasons, both +// measured rather than defensive. A rim-bright profile drives the inner disc +// toward zero, and near a depth edge the outer rings are all rejected by the +// `sc > min_sc` test -- so the surviving samples would carry almost no weight +// and the pixel would fall back to its own colour while its neighbour blurred +// normally, which reads as speckle along every defocus transition. And a +// creamy profile lands exactly 0.0 on the outermost ring, the largest one, so +// its samples were fetched, tinted and multiplied away: 40% of all taps at 4px +// of blur. At 0.15 the centre-tap share tracks the unaberrated baseline to +// within a percent at every blur size, and both profiles still read correctly. +const float BOKEH_SHAPE_FLOOR = 0.15; + +// How much this sample counts, before its radiance is weighed. Both aberrations +// are per-sample scalars on the same accumulation, so they combine into one +// branchless expression -- cheaper than gating each, and it avoids a divergent +// branch on `apod`, which varies per fragment through the blur-size fade. +// +// Spherical aberration redistributes weight across the disc. The gather is +// close to area-uniform -- ring sample counts grow with radius while rings stay +// one pixel apart -- so a per-sample weight is very nearly the bokeh's radial +// profile. Not exactly: int(sc*3.7) truncates a fraction of a sample from every +// ring, which biases density by up to 5% at small radii, and the outermost ring +// sits at radius_norm 1.0 where a continuous integral would half-weight it. The +// continuous form of (2r^2 - 1) has an area-weighted mean of zero, so the mean +// weight is 1 in the limit; the discrete walk deviates by up to a quarter at +// 3-4px of blur. It does not matter while the gather normalises by `w`, and it +// would matter a great deal if that normalisation were ever removed. +// +// `apod` arrives multiplied by -cof_sign and by the blur-size fade. `coma_vec` +// carries the comatic bias as one vector: its direction is the axis and its +// length is the strength, both baked per fragment. +float bokehShapeWeight(float radius_norm, vec2 samp_dir, float apod, vec2 coma_vec) +{ + float sw = 1.0 + + apod * (2.0 * radius_norm * radius_norm - 1.0) + + dot(samp_dir, coma_vec) * radius_norm; + + return max(sw, BOKEH_SHAPE_FLOOR); +} + +// 1.0 if this sample falls inside the aperture, 0.0 if a blade or the cat's-eye +// clip excludes it. `radius_norm` is the sample's position across the disc, +// 0 at the centre and 1 at the rim. +float apertureMask(float ang, float radius_norm, vec2 cat_offset) +{ + float edge = 1.0; + + if (uBokehBlades >= 3) { - float wg = 0.25; + // Inscribed radius of a regular N-gon at this angle: + // cos(pi/N) / cos(mod(theta + rot, 2pi/N) - pi/N) + // The three constants come pre-baked so the loop does no trig setup, + // only the one cosine it genuinely needs per sample. + float half_sector = uBokehApertureConst.x; + float sector = uBokehApertureConst.y; + float apothem = uBokehApertureConst.z; + + float t = mod(ang + uBokehApertureRotation, sector) - half_sector; + float poly = apothem / max(cos(t), 1e-3); - // de-weight dull areas to make highlights 'pop' - wg += s.r+s.g+s.b; + // Curvature relaxes the straight blades back toward a circle, which is + // what a rounded diaphragm actually produces. + edge = mix(poly, 1.0, clamp(uBokehApertureCurvature, 0.0, 1.0)); + } - diff += wg*s; + if (radius_norm > edge) + { + return 0.0; + } - w += wg; + if (uBokehCatEye > 0.0) + { + // Optical (mechanical) vignetting. The aperture an off-axis ray sees is + // the intersection of the diaphragm with the lens barrel, and that + // second opening slides further off-centre the further the ray is from + // the axis -- so discs near the frame edge are clipped into lens-shaped + // slivers that lean away from centre, while the middle stays round. + vec2 p = vec2(sin(ang), cos(ang)) * radius_norm; + vec2 d = p - cat_offset; + if (dot(d, d) > 1.0) + { + return 0.0; + } } + + return 1.0; } -void dofSampleNear(inout vec4 diff, inout float w, float min_sc, vec2 tc) +// Longitudinal chromatic aberration. Real glass brings different wavelengths to +// focus at slightly different distances, so a defocused edge picks up a colour +// cast whose hue flips either side of the focal plane -- the familiar magenta +// in front, green behind. Approximated by tinting each sample by how far out in +// the disc it sits, which puts the cast on the disc's rim where it belongs, and +// choosing the tint by the sign of the circle of confusion. +// +// Distinct from the lateral chromatic aberration in colorCorrect: that one is a +// whole-frame radial fringe that grows toward the corners and is present +// whether or not anything is defocused. +vec3 bokehFringe(vec3 c, float radius_norm, float cof_sign) { - vec4 s = texture(diffuseRect, tc); + if (uBokehFringeAmount <= 0.0) + { + return c; + } - float wg = 0.25; + vec3 tint = (cof_sign < 0.0) ? uBokehFringeFarTint : uBokehFringeNearTint; + return c * mix(vec3(1.0), tint, clamp(radius_norm, 0.0, 1.0) * uBokehFringeAmount); +} +#endif - // de-weight dull areas to make highlights 'pop' - wg += s.r+s.g+s.b; +// Note the centre sample in main() is deliberately neither clamped nor +// radiance-weighted. It is this pixel's own value, and when the pixel is in +// focus the gather loops never run at all -- clamping it there would clip +// in-focus highlights, which is the opposite of what the clamp is for. It IS +// shape-weighted, so a rim-bright profile does not leave the sharp image +// bleeding through the middle of the disc; see the centre tap in main(). +// +// radius_norm and cof_sign are only read in the shaped build; the unshaped one +// discards them along with the fringe call. +// +// One accumulate body shared by both gathers, so the near and far fields can +// never weight samples differently -- a one-sided edit to the clamp or fringe +// would otherwise show up as a subtle front/back blur mismatch. +void dofAccumulate(inout vec4 diff, inout float w, vec4 s, float radius_norm, float cof_sign, float shape_w) +{ + vec3 c = bokehClamp(s.rgb); +#if DOF_SHAPED + c = bokehFringe(c, radius_norm, cof_sign); +#endif + vec4 cs = vec4(c, s.a); + float wg = bokehWeight(cs.rgb) * shape_w; - diff += wg*s; + diff += wg*cs; w += wg; } +void dofSample(inout vec4 diff, inout float w, float min_sc, vec2 tc, float radius_norm, float cof_sign, float shape_w) +{ + vec4 s = texture(diffuseRect, tc); + + float sc = abs(s.a*2.0-1.0)*max_cof; + + if (sc > min_sc) //sampled pixel is more "out of focus" than current sample radius + { + dofAccumulate(diff, w, s, radius_norm, cof_sign, shape_w); + } +} + +void dofSampleNear(inout vec4 diff, inout float w, vec2 tc, float radius_norm, float cof_sign, float shape_w) +{ + dofAccumulate(diff, w, texture(diffuseRect, tc), radius_norm, cof_sign, shape_w); +} + vec3 clampHDRRange(vec3 color); void main() @@ -80,26 +275,206 @@ void main() vec4 diff = texture(diffuseRect, vary_fragcoord.xy); { - float w = 1.0; - float sc = (diff.a*2.0-1.0)*max_cof; float PI = 3.14159265358979323846264; + // Outermost ring radius and which side of focus we are on. The rings + // walk inward from here, so sc/max_radius is the sample's position + // across the disc: 1.0 at the rim, approaching 0 at the centre. Both + // the aperture shape and the fringe are defined in those terms. + float max_radius = max(abs(sc), 1e-4); + float cof_sign = (sc < 0.0) ? -1.0 : 1.0; + +#if DOF_SHAPED + vec2 cat_offset = vec2(0.0); + vec2 ax = vec2(1.0, 0.0); + vec2 ay = vec2(0.0, 1.0); + vec2 coma_vec = vec2(0.0); + float apod_signed = 0.0; + float ring_density = 1.0; + + // Everything below is only read by the gather loops, so fragments that + // are in focus -- most of the frame at a mild setting -- skip the lot. + // It matters more than it looks: the block carries several divides, two + // square roots and a smoothstep, where before these effects existed it + // was two scalar assignments. + if (abs(sc) > 0.5) + { + // Where this fragment sits in the frame, measured the way every + // other radial effect in the stack measures it: aspect-corrected, + // then normalised over the half-diagonal so the corner reads 1.0 on + // any viewport shape. Measured in raw UV instead, "distance from + // the optical axis" reaches 1.0 at the left edge of a 21:9 frame + // and 1.0 at its top edge, which are nowhere near the same distance + // -- and the effects keyed off it then follow the viewport + // rectangle rather than the lens's image circle. + float dof_aspect = screen_res.x / max(screen_res.y, 1.0); + vec2 dof_ascale = max(vec2(dof_aspect, 1.0 / max(dof_aspect, 1e-4)), 1.0); + vec2 field_vec = (vary_fragcoord.xy - 0.5) * dof_ascale; + float field_len = length(field_vec); + vec2 field_dir = (field_len > 1e-5) ? (field_vec / field_len) : vec2(0.0); + float field_r = clamp(field_len / (0.5 * length(dof_ascale)), 0.0, 1.0); + + // Aberrations fade in with blur size. One ring cannot carry a + // radial profile: below about 1.5px the disc is a single ring + // sitting at radius_norm 1.0, so a shaped weight there is applied + // to every surviving sample at once and the blur either collapses + // or goes one-sided. Fading to zero leaves those pixels behaving + // exactly as they do without the effect. + float shape_fade = smoothstep(1.5, 4.0, max_radius); + + // Offset of the barrel opening for optical vignetting, growing with + // distance from the optical axis. + cat_offset = field_dir * (field_r * uBokehCatEye); + + // Anamorphic deformation. A cylindrical element squeezes the image on + // one axis, and out-of-focus highlights inherit that squeeze as ovals + // -- the format's most recognisable signature. Applied to the sample + // offsets *after* the aperture test, not to the test itself: the + // diaphragm is whatever shape it is, and the cylinder stretches the + // disc that results, so blades and cat's-eye slivers stretch with it. + // + // The CPU sends the anamorphic squeeze area-preserving (the two + // axes multiply to 1), so the control changes the shape of the blur + // without also changing how much of it there is. + // + // Anamorphic alone is a diagonal matrix, which is what this used to + // be as two scalars. Field stretch adds a second squeeze on the + // radial axis, so the pair becomes a general 2x2 carried as its two + // column vectors. With field stretch off it reduces to (anam.x, 0) + // and (0, anam.y) -- the old behaviour, bit for bit. + if (uBokehFieldStretch != 0.0 && field_len > 1e-5) + { + // Stretch across the radius for swirl, along it for coma. The + // sqrt makes the axes s and 1/s, so the deformation is + // area-preserving and a highlight keeps its brightness as it + // deforms. max() on the falloff because a zero exponent would + // make pow() return 1.0 everywhere and stretch the on-axis disc + // as hard as the corners. + vec2 u = (uBokehFieldStretch > 0.0) + ? vec2(-field_dir.y, field_dir.x) // across the radius + : field_dir; // along it + float s = sqrt(1.0 + abs(uBokehFieldStretch) + * pow(field_r, max(uBokehFieldFalloff, 1.0))); + + // A symmetric stretch by s along u is (1/s)I + (s - 1/s)uu^T. + // field_dir is already the unit vector the rotation would have + // rebuilt, so there is no angle to recover and no trig here. + float k = s - 1.0 / s; + ax = vec2(1.0 / s + k * u.x * u.x, k * u.x * u.y); + ay = vec2(k * u.x * u.y, 1.0 / s + k * u.y * u.y); + } + + // Anamorphic applied outermost: the cylindrical element squeezes + // the whole image, including whatever shape the field aberration + // has already produced. + ax *= uBokehAnamorphic; + ay *= uBokehAnamorphic; + + // Largest singular value of the basis -- how far its widest axis + // has been stretched. Ring sample counts scale by it so a deformed + // disc does not thin out into visible rings. This reduces exactly + // to max(anam.x, anam.y) when there is no field stretch, which is + // what the line used to be, and it cannot be replaced by + // max(old, new) once field stretch is live: the two stretch axes + // can oppose, and the true maximum then sits *below* max(anam). + // Capped because it multiplies the tap count directly, and the only + // other bound on it is a pair of CPU clamps two files away. + float bF = dot(ax, ax) + dot(ay, ay); + float bD = ax.x * ay.y - ax.y * ay.x; + ring_density = min(sqrt(max(0.5 * (bF + sqrt(max(bF * bF - 4.0 * bD * bD, 0.0))), 1e-4)), 3.0); + + // Spherical aberration. Multiplied by -cof_sign, not cof_sign: + // cof_sign is +1 in front of the focal plane, so anchoring the + // control to the foreground would invert it for the background -- + // and the background is the only field the default build renders, + // since RenderDepthOfFieldNearBlur defaults off and compiles the + // near gather out. Positive now means a bright rim behind focus, + // which is what the setting says it means. + apod_signed = uBokehSpherical * shape_fade * -cof_sign; + + // Comatic asymmetry, as one vector: direction is the bias axis, + // length is the strength. + // + // Two things here are easy to get backwards, and both were. + // + // This pass is a *gather*: a fragment reads its neighbours, so a + // point source renders as the weight function mirrored through the + // origin. Favouring outward samples therefore deposits light on the + // inward side and the comet points at the frame centre. The axis is + // negated so the rendered flare runs outward, the way real coma and + // this setting's own description both say it should. + // + // And the axis has to be measured in the disc's *parameter* space, + // because that is where the samples are chosen. The screen-space + // centroid is M times the parameter-space centroid, so biasing + // along M^T(field) lands the comet along M M^T(field) -- 41 degrees + // off with a strong anamorphic squeeze. Biasing along the inverse + // instead puts it back exactly on the field direction. det(M) is + // the anamorphic product, which the CPU sends as 1 and which is + // positive regardless, so the adjugate serves and the 1/det drops + // out in the normalise. + if (uBokehComaAsymmetry != 0.0 && field_len > 1e-5) + { + vec2 g = vec2(ay.y * field_dir.x - ay.x * field_dir.y, + ax.x * field_dir.y - ax.y * field_dir.x); + float gl = length(g); + if (gl > 1e-5) + { + coma_vec = -(g / gl) + * (uBokehComaAsymmetry * field_r * shape_fade); + } + } + } +#else + const float ring_density = 1.0; +#endif + + // The centre tap is the sample at radius_norm 0, so it carries that + // position's weight rather than a bare 1.0. Leaving it unweighted is + // what let a rim-bright profile bleed the sharp image through wherever + // the surrounding ring samples were rejected. +#if DOF_SHAPED + float w = bokehShapeWeight(0.0, vec2(0.0), apod_signed, coma_vec); +#else + float w = 1.0; +#endif + diff *= w; + // sample quite uniformly spaced points within a circle, for a circular 'bokeh' #if FRONT_BLUR if (sc > 0.5) { while (sc > 0.5) { - int its = int(max(1.0,(sc*3.7))); + int its = int(max(1.0,(sc*3.7*ring_density))); for (int i=0; i 0.5) { - int its = int(max(1.0,(sc*3.7))); + int its = int(max(1.0,(sc*3.7*ring_density))); for (int i=0; i 1 decays faster +uniform float uCrossChromatic; // 0 = white streaks, 1 = full dispersion +uniform float uCrossPassScale; // 1, TAPS, TAPS^2 across the three iterations + +in vec2 vary_texcoord0; + +// Four taps per pass with the stride quadrupling to match is not a tuning +// choice -- it is the whole trick. +// +// Composing the three passes puts a sample at every offset i + 4j + 16k for +// i, j, k in 0..3. That is base-4 positional notation, so the chain reaches +// every integer offset from 0 to 63 exactly once, weighted falloff^-offset: +// an exact exponential line filter from twelve taps instead of sixty-four. +// +// Two things break that, and both were shipped and had to be found the hard +// way: +// +// - TAPS not matching the stride. Six taps against a stride of four covers +// the same span with multiplicity running 1,1,1,1,2,2,1,1,2,2,... -- a +// modulation repeating at 4 and again at 16, which reads as self-similar +// spikes along every arm. +// +// - Sampling more than one direction per pass. The tiling argument assumes +// offsets accumulate one-sided. Let a pass also step backwards and the net +// offset becomes +/-i +/-4j +/-16k with independent signs: 127 offsets +// instead of 64, reached by paths whose weight is set by how far the path +// travelled rather than where it ended. Measured against a clean +// exponential that is a 73% error with ten places where the arm gets +// *brighter* further out. +// +// So this shader streaks exactly one direction, one-sided, and the caller runs +// a separate three-pass chain per arm. uCrossLength must also stay near one +// texel: it multiplies every offset, so at 2 the chain lands on even texels +// only and real gaps open between them. +// +// CROSS_TAPS is injected at compile time from CROSS_FILTER_TAPS +// (llviewershadermgr.h) -- the same constant pipeline.cpp derives the pass +// strides and the falloff remap from, so the three moving parts of the +// tiling can no longer disagree. +const int TAPS = CROSS_TAPS; +const float CHAIN_REACH = float(TAPS * TAPS * TAPS - 1); + +// Cheap spectrum for the dispersion. A real star filter is a diffraction +// grating, and a grating separates wavelengths by angle -- which is why the +// tips of the streaks go rainbow while the core stays white. Three overlapping +// triangular lobes are enough to read as that without a LUT. +vec3 spectrum(float t) +{ + float s = clamp(t, 0.0, 1.0) * 3.0; + return clamp(vec3(1.5 - abs(s - 0.5), + 1.5 - abs(s - 1.5), + 1.5 - abs(s - 2.5)), 0.0, 1.0); +} + +void main() +{ + vec3 accum = vec3(0.0); + float total_w = 0.0; + + for (int i = 0; i < TAPS; ++i) + { + float step_index = float(i) * uCrossPassScale; + vec2 offset = uCrossDir * uCrossTexel * uCrossLength * step_index; + + // Attenuation is exponential in the step index, so the three passes + // compose into one continuous exponential rather than three banded + // ones: weight(a) * weight(b) == weight(a + b). + float weight = pow(uCrossFalloff, -step_index); + + vec3 tint = vec3(1.0); + if (uCrossChromatic > 0.0) + { + // Normalised against the chain's full reach so the hue sweep spans + // the whole arm. + float t = clamp(step_index / CHAIN_REACH, 0.0, 1.0); + + // Normalised so the three lobes always average to white. + // Dispersion redistributes a tap's energy across the channels; it + // must not add or remove any, or the dispersion slider doubles as a + // brightness slider. + vec3 sp = spectrum(t); + sp *= 3.0 / max(sp.r + sp.g + sp.b, 1e-4); + + // The ramp by t keeps the core white, and it is not cosmetic. A + // grating deviates by wavelength, so at zero deviation every + // wavelength lands in the same place: the centre of a streak is + // white by construction and only the tips separate into colour. + tint = mix(vec3(1.0), sp, uCrossChromatic * t); + } + + accum += texture(diffuseMap, vary_texcoord0 + offset).rgb * weight * tint; + total_w += weight; + } + + // Normalise by the total tap weight, making this a weighted average along + // the arm rather than a sum, so a uniform region passes through unchanged + // and falloff shapes the arm without also setting its brightness. + accum /= max(total_w, 1e-4); + + frag_color = vec4(accum, 0.0); +} diff --git a/indra/newview/app_settings/shaders/class1/effects/lensDirtGenF.glsl b/indra/newview/app_settings/shaders/class1/effects/lensDirtGenF.glsl new file mode 100644 index 0000000000..c12b0b28fc --- /dev/null +++ b/indra/newview/app_settings/shaders/class1/effects/lensDirtGenF.glsl @@ -0,0 +1,267 @@ +/** + * @file lensDirtGenF.glsl + * @brief Generates the lens dirt plate into a texture. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Alchemy Viewer Source Code + * Copyright (C) 2026, Alchemy Viewer Project + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * $/LicenseInfo$ + */ + +/*[EXTRA_CODE_HERE]*/ + +// Muck on the front element: defocused dust, wipe smudges, stray fibres, fine +// grit, and optionally scratches and coating chips. The result is a scalar mask +// that colorCorrect multiplies bloom and flare by, so only relative brightness +// matters and the plate is heavily biased to black. +// +// This runs ONCE into a texture whenever a parameter changes or the window +// resizes -- not per frame. That budget is the whole reason the effect can be +// procedural at all: a few hundred ALU per pixel is unthinkable in the +// per-frame path and unremarkable in a one-off, which is what lets this draw +// discrete features rather than settling for whatever a couple of octaves of +// noise happen to look like. +// +// Generating at the target's own resolution also retires the cover-fit the +// baked plates needed. A square image sampled with screen UV stretches every +// round mote into an ellipse on a wide display; there is no fitting to do when +// the plate is made at the shape it will be read at. + +out vec4 frag_color; + +in vec2 vary_texcoord0; + +uniform vec2 uDirtResolution; // plate size in pixels; only the ratio is read +uniform float uDirtSeed; // reshuffles every layer +uniform float uDirtGrime; // master density +uniform float uDirtMoteScale; // >1 enlarges the motes and thins them out +uniform float uDirtSmudge; // wipe-mark strength +uniform int uDirtScratches; // 0 for undamaged glass +uniform float uDirtToe; // tone curve exponent +uniform float uDirtGain; // tone curve gain + +// Upper bound on the segment loops, injected at compile time from +// LENS_DIRT_MAX_LINES so the CPU clamp and the loop bound cannot drift apart. +// The loop is bounded rather than run to a uniform so the compiler can unroll +// it instead of branching per iteration on a value it cannot see. +const int MAX_LINES = DIRT_MAX_LINES; + +// ---------------------------------------------------------------- hashes ---- + +float hash21(vec2 p, float seed) +{ + return fract(sin(p.x * 127.1 + p.y * 311.7 + seed * 74.7) * 43758.5453); +} + +vec2 hash22(vec2 p, float seed) +{ + return fract(vec2(sin(p.x * 127.1 + p.y * 311.7 + seed * 74.7), + sin(p.x * 269.5 + p.y * 183.3 + seed * 51.3)) * 43758.5453); +} + +float screenBlend(float a, float b) +{ + return 1.0 - (1.0 - a) * (1.0 - b); +} + +// ------------------------------------------------------------ mote layer ---- + +// One jittered disc per grid cell, scanned over the 3x3 neighbourhood so a disc +// crossing a cell boundary still registers. +// +// Three details separate this from looking like plain cellular noise, and all +// three were found by comparing bakes against the plates this replaces. +// +// Cells are dropped when their hash exceeds `density`: one feature per cell is +// perfectly even, and dirt is not -- it pools and leaves clean glass between. +// Radii are raised to `bias` so most motes come out small with a few large, +// rather than filling the range uniformly. And the profile is a flat interior +// with a quick rim rather than a Gaussian, because the reference motes were +// drawn as solid shapes and lightly blurred; a Gaussian has no edge anywhere +// and reads as fog the moment neighbouring blobs overlap. `softness` is the +// fraction of the radius the rim occupies. +float dirtMotes(vec2 uv, float cells, float r_min, float r_max, float softness, + float seed, float aspect, float density, float bias) +{ + float acc = 0.0; + vec2 cell = floor(uv * cells); + + for (int ox = -1; ox <= 1; ++ox) + { + for (int oy = -1; oy <= 1; ++oy) + { + vec2 g = cell + vec2(float(ox), float(oy)); + float keep = step(hash21(g, seed + 57.0), density); + vec2 f = (g + hash22(g, seed)) / cells; + float rad = r_min + (r_max - r_min) * pow(hash21(g, seed + 19.0), bias); + + vec2 d2 = vec2((uv.x - f.x) * aspect, uv.y - f.y); + float d = length(d2); + + float x = clamp((rad - d) / (rad * softness + 1e-9), 0.0, 1.0); + acc = screenBlend(acc, x * x * (3.0 - 2.0 * x) * keep); + } + } + return acc; +} + +// ----------------------------------------------------------- value noise ---- + +float vnoise(vec2 uv, float freq, float seed) +{ + vec2 p = uv * freq; + vec2 i = floor(p); + vec2 f = p - i; + f = f * f * (3.0 - 2.0 * f); + + float a = hash21(i, seed); + float b = hash21(i + vec2(1.0, 0.0), seed); + float c = hash21(i + vec2(0.0, 1.0), seed); + float d = hash21(i + vec2(1.0, 1.0), seed); + + return mix(mix(a, b, f.x), mix(c, d, f.x), f.y); +} + +float fbm3(vec2 uv, float freq, float seed) +{ + float total = 0.0; + float amp = 1.0; + float norm = 0.0; + for (int i = 0; i < 3; ++i) + { + total += amp * vnoise(uv, freq * exp2(float(i)), seed + float(i) * 13.0); + norm += amp; + amp *= 0.5; + } + return total / norm; +} + +// ------------------------------------------------------------- segments ----- + +float segDist(vec2 uv, vec2 a, vec2 b, float aspect) +{ + vec2 pa = vec2((uv.x - a.x) * aspect, uv.y - a.y); + vec2 ba = vec2((b.x - a.x) * aspect, b.y - a.y); + float h = clamp(dot(pa, ba) / max(dot(ba, ba), 1e-9), 0.0, 1.0); + return length(pa - ba * h); +} + +// Fibres and scratches. A segment distance field, optionally domain-warped by +// low-frequency noise so a fibre wanders while a scratch stays straight -- the +// only difference between a thread lying on the glass and a wipe with grit in +// the cloth. +float dirtLines(vec2 uv, int count, float length_, float width, float seed, + float aspect, float wander) +{ + vec2 w = uv; + if (wander > 0.0) + { + w += (vec2(fbm3(uv, 3.0, seed + 7.0), fbm3(uv, 3.0, seed + 31.0)) - 0.5) * wander; + } + + float acc = 0.0; + for (int i = 0; i < MAX_LINES; ++i) + { + if (i >= count) + { + break; + } + float fi = float(i); + vec2 a = vec2(hash21(vec2(fi, 1.0), seed), hash21(vec2(fi, 2.0), seed)); + float ang = hash21(vec2(fi, 3.0), seed) * 6.2831853; + float len = length_ * (0.4 + 0.6 * hash21(vec2(fi, 4.0), seed)); + vec2 b = a + vec2(cos(ang), sin(ang)) * len; + float wid = width * (0.5 + 0.5 * hash21(vec2(fi, 5.0), seed)); + + float d = segDist(w, a, b, aspect); + float x = clamp((wid - d) / max(wid, 1e-9), 0.0, 1.0); + acc = max(acc, x * x * (3.0 - 2.0 * x) * (0.55 + 0.45 * hash21(vec2(fi, 6.0), seed))); + } + return acc; +} + +// ---------------------------------------------------------------- build ----- + +void main() +{ + vec2 uv = vary_texcoord0; + float aspect = uDirtResolution.x / max(uDirtResolution.y, 1.0); + float seed = uDirtSeed; + float grime = uDirtGrime; + float mscale = max(uDirtMoteScale, 0.05); + + // Where the muck gathers. Dirt does not spread evenly over a lens: it pools + // and leaves other areas nearly clean, and modulating the mote layers by a + // low-frequency field is what turns an even scatter into something that + // looks like it settled there. + // + // The frequency is load-bearing for a reason that is not obvious. A clump + // field with only a handful of features across the plate has a spatial mean + // that swings from seed to seed, and since it multiplies every mote layer + // that swing becomes the plate's overall density -- at a low frequency the + // same settings covered anywhere from 19% to 76% of the frame on nothing + // but the seed. More features average the mean back toward the middle, and + // the narrow output range keeps what wobble remains away from the result. + float clump = fbm3(uv, 5.0, seed + 101.0); + clump = 0.62 + 0.76 * clamp((clump - 0.34) / 0.36, 0.0, 1.0); + + float img = 0.0; + + // Motes, large relative to their spacing so they overlap into a mottled + // field rather than reading as separate blobs. + img = screenBlend(img, dirtMotes(uv, 4.5 / mscale, 0.050, 0.140, 0.45, + seed, aspect, 0.80 * grime, 1.7) * 0.46 * clump); + img = screenBlend(img, dirtMotes(uv, 9.0 / mscale, 0.022, 0.064, 0.42, + seed + 3.0, aspect, 0.75 * grime, 1.9) * 0.50 * clump); + img = screenBlend(img, dirtMotes(uv, 20.0 / mscale, 0.009, 0.028, 0.40, + seed + 5.0, aspect, 0.60 * grime, 2.1) * 0.44 * clump); + + // Fine grit. Small and sharp, and the layer most easily overdone: too much + // and the plate reads as a starfield rather than as dirt. + img = screenBlend(img, dirtMotes(uv, 90.0, 0.0011, 0.0030, 0.55, + seed + 9.0, aspect, 0.34 * grime, 1.5) * 0.75); + + // Wipe smudges: broad and low contrast, a cloth pushed across the glass. + float sm = fbm3(uv, 1.6, seed + 21.0); + img = screenBlend(img, pow(clamp((sm - 0.50) / 0.34, 0.0, 1.0), 1.4) * 0.34 * uDirtSmudge); + + // Stray fibres. + img = screenBlend(img, dirtLines(uv, 12, 0.20, 0.0014, seed + 41.0, aspect, 0.06) * 0.62); + + if (uDirtScratches > 0) + { + img = screenBlend(img, dirtLines(uv, uDirtScratches, 0.55, 0.0022, + seed + 57.0, aspect, 0.0)); + } + + // Keep the middle of the frame clearer. The plate multiplies bloom, and the + // subject usually sits in the centre, so grime there veils exactly what the + // viewer is looking at. The range is wider than it looks like it needs to + // be because the clump field above swings harder than this does, and a + // gentler curve simply disappeared underneath it. + vec2 c = vec2((uv.x - 0.5) * aspect, uv.y - 0.5); + float r = clamp(length(c) / (0.5 * length(vec2(aspect, 1.0))), 0.0, 1.0); + img *= 0.22 + 0.78 * pow(r, 0.75); + + // Shape the low end. The layers above are deliberately generous with area, + // and this is what decides whether the plate reads at all: crushing it too + // hard removes the midtones dirt actually lives in and leaves a few bright + // dots that stay invisible however high the strength goes. + float v = clamp(uDirtGain * pow(clamp(img, 0.0, 1.0), uDirtToe), 0.0, 1.0); + + frag_color = vec4(v, v, v, 1.0); +} diff --git a/indra/newview/llpresetsmanager.cpp b/indra/newview/llpresetsmanager.cpp index 320330098c..a98cdc1e6f 100644 --- a/indra/newview/llpresetsmanager.cpp +++ b/indra/newview/llpresetsmanager.cpp @@ -43,10 +43,74 @@ #include "llagentcamera.h" #include "llfile.h" +#if !LL_RELEASE_FOR_DOWNLOAD +// The Looks whitelist, the settings declarations, and the bundled Look files +// must stay in lockstep by hand, and every consumer fails silent on drift: +// loadLooksPreset writes only keys present in BOTH the whitelist and the +// file, so a bundled Look that falls behind quietly stops resetting the keys +// it lacks -- and "Neutral" stops meaning neutral. Say so loudly at startup +// instead. One parse of a handful of small files, once per session. +static void audit_bundled_looks(const std::vector& whitelist) +{ + const std::string app_dir = gDirUtilp->getExpandedFilename(LL_PATH_APP_SETTINGS, PRESETS_LOOKS); + std::string file; + LLDirIterator look_iter(app_dir, "*.xml"); + while (look_iter.next(file)) + { + llifstream look_stream(gDirUtilp->add(app_dir, file)); + if (!look_stream.is_open()) + { + continue; + } + LLSD look; + LLSDSerialize::fromXML(look, look_stream); + if (!look.isMap()) + { + LL_WARNS("Presets") << "Bundled Look '" << file << "' is not a settings map" << LL_ENDL; + continue; + } + for (const std::string& name : whitelist) + { + if (!look.has(name)) + { + LL_WARNS("Presets") << "Bundled Look '" << file << "' is missing whitelisted key '" + << name << "'; applying it will leave that setting untouched" << LL_ENDL; + continue; + } + + // Presence was never the only way these files rot. Each Look + // carries a full copy of every setting's Comment, and nothing reads + // those copies -- loadLooksPreset takes only Value -- so a reworded + // description in settings_alchemy.xml leaves three stale duplicates + // behind with no symptom at all until someone diffs them by hand. + // Five had already drifted that way before this check existed. + const LLControlVariable* ctrl = gSavedSettings.getControl(name).get(); + if (ctrl && look[name].isMap() && look[name].has("Comment") + && look[name]["Comment"].asString() != ctrl->getComment()) + { + LL_WARNS("Presets") << "Bundled Look '" << file << "' has a stale Comment for '" + << name << "'; it no longer matches the setting's own description" + << LL_ENDL; + } + } + } +} +#endif // !LL_RELEASE_FOR_DOWNLOAD + LLPresetsManager::LLPresetsManager() { copyDefaultLooks(); +#if !LL_RELEASE_FOR_DOWNLOAD + // Developer check, not a runtime one: it verifies that files in the source + // tree agree with each other, which a shipped build can do nothing about. + { + std::vector looks_whitelist; + getLooksControlNames(looks_whitelist); + audit_bundled_looks(looks_whitelist); + } +#endif + // Connect preset signals startWatching(PRESETS_GRAPHIC); startWatching(PRESETS_CAMERA); @@ -608,6 +672,53 @@ void LLPresetsManager::getLooksControlNames(std::vector& names) "RenderChromaticAberrationOffsetRY", "RenderChromaticAberrationOffsetBX", "RenderChromaticAberrationOffsetBY", + // Lens distortion + "RenderLensDistortionAmount", + "RenderLensDistortionK1", + "RenderLensDistortionK2", + "RenderLensDistortionSqueeze", + "RenderLensDistortionFit", + "RenderLensDistortionCenter", + "RenderLensDistortionTangential", + // Bokeh aesthetics. The camera optics themselves (CameraFNumber, + // CameraFocalLength and friends) are deliberately absent from this + // list -- a Look is an aesthetic, not a shot setup -- and + // RenderBokehHighlightClamp stays out for a third reason: it is a + // firefly guard like RenderBloomFireflyClamp, a stability control + // rather than a look. + "RenderBokehHighlightGain", + "RenderBokehHighlightThreshold", + "RenderBokehApertureBlades", + "RenderBokehApertureRotation", + "RenderBokehApertureCurvature", + "RenderBokehAnamorphicSqueeze", + "RenderBokehCatEyeAmount", + "RenderBokehFringeAmount", + "RenderBokehFringeNearTint", + "RenderBokehFringeFarTint", + // Aberrations contributed by the glass rather than the iris + "RenderBokehSphericalAberration", + "RenderBokehFieldStretch", + "RenderBokehFieldFalloff", + "RenderBokehComaAsymmetry", + // Cross-screen filter + "RenderCrossFilterStrength", + "RenderCrossFilterPoints", + "RenderCrossFilterAngle", + "RenderCrossFilterLength", + "RenderCrossFilterFalloff", + "RenderCrossFilterChromatic", + // Lens dirt + "RenderLensDirtStrength", + "RenderLensDirtBloomResponse", + "RenderLensDirtFlareResponse", + "RenderLensDirtGrime", + "RenderLensDirtMoteScale", + "RenderLensDirtSmudge", + "RenderLensDirtScratches", + "RenderLensDirtSeed", + "RenderLensDirtToe", + "RenderLensDirtGain", // Vignette "RenderVignetteAmount", "RenderVignetteCenter", diff --git a/indra/newview/llviewershadermgr.cpp b/indra/newview/llviewershadermgr.cpp index 30958bb291..4a7b5754ee 100644 --- a/indra/newview/llviewershadermgr.cpp +++ b/indra/newview/llviewershadermgr.cpp @@ -170,6 +170,8 @@ LLGLSLShader gBloomDownsampleProgram; LLGLSLShader gBloomDownsampleFirstProgram; LLGLSLShader gBloomUpsampleProgram; LLGLSLShader gBloomCompositeProgram; +LLGLSLShader gCrossFilterProgram; +LLGLSLShader gLensDirtGenProgram; // Deferred rendering shaders LLGLSLShader gDeferredImpostorProgram; @@ -217,6 +219,8 @@ LLGLSLShader gDeferredEmissiveProgram; LLGLSLShader gDeferredEmissiveIndexedProgram; // multi-material indexed legacy glow LLGLSLShader gDeferredPostProgram; LLGLSLShader gDeferredPostProgramNoNear; +LLGLSLShader gDeferredPostProgramShaped; +LLGLSLShader gDeferredPostProgramNoNearShaped; LLGLSLShader gDeferredCoFProgram; LLGLSLShader gDeferredDoFCombineProgram; LLGLSLShader gExposureProgram; @@ -1110,6 +1114,8 @@ bool LLViewerShaderMgr::loadShadersEffects() gBloomDownsampleFirstProgram.unload(); gBloomUpsampleProgram.unload(); gBloomCompositeProgram.unload(); + gCrossFilterProgram.unload(); + gLensDirtGenProgram.unload(); return true; } @@ -1197,6 +1203,30 @@ bool LLViewerShaderMgr::loadShadersEffects() success = gBloomUpsampleProgram.createShader(); } + if (success) + { + gCrossFilterProgram.mName = "Cross Screen Filter"; + gCrossFilterProgram.mShaderFiles.clear(); + gCrossFilterProgram.mShaderFiles.push_back(make_pair("effects/glowExtractV.glsl", GL_VERTEX_SHADER)); + gCrossFilterProgram.mShaderFiles.push_back(make_pair("effects/crossFilterF.glsl", GL_FRAGMENT_SHADER)); + gCrossFilterProgram.mShaderLevel = mShaderLevel[SHADER_EFFECT]; + gCrossFilterProgram.clearPermutations(); + gCrossFilterProgram.addPermutation("CROSS_TAPS", std::to_string(CROSS_FILTER_TAPS)); + success = gCrossFilterProgram.createShader(); + } + + if (success) + { + gLensDirtGenProgram.mName = "Lens Dirt Generator"; + gLensDirtGenProgram.mShaderFiles.clear(); + gLensDirtGenProgram.mShaderFiles.push_back(make_pair("effects/glowExtractV.glsl", GL_VERTEX_SHADER)); + gLensDirtGenProgram.mShaderFiles.push_back(make_pair("effects/lensDirtGenF.glsl", GL_FRAGMENT_SHADER)); + gLensDirtGenProgram.mShaderLevel = mShaderLevel[SHADER_EFFECT]; + gLensDirtGenProgram.clearPermutations(); + gLensDirtGenProgram.addPermutation("DIRT_MAX_LINES", std::to_string(LENS_DIRT_MAX_LINES)); + success = gLensDirtGenProgram.createShader(); + } + if (success) { gBloomCompositeProgram.mName = "HDR Bloom Composite"; @@ -1265,6 +1295,9 @@ bool LLViewerShaderMgr::loadShadersDeferred() gDeferredEmissiveProgram.unload(); gDeferredEmissiveIndexedProgram.unload(); gDeferredPostProgram.unload(); + gDeferredPostProgramNoNear.unload(); + gDeferredPostProgramShaped.unload(); + gDeferredPostProgramNoNearShaped.unload(); gDeferredCoFProgram.unload(); gDeferredDoFCombineProgram.unload(); gExposureProgram.unload(); @@ -2945,32 +2978,45 @@ bool LLViewerShaderMgr::loadShadersDeferred() if (success) { - gDeferredPostProgram.mName = "Deferred Post Shader"; - gDeferredPostProgram.mFeatures.isDeferred = true; - gDeferredPostProgram.mShaderFiles.clear(); - gDeferredPostProgram.mShaderFiles.push_back(make_pair("deferred/postDeferredNoTCV.glsl", GL_VERTEX_SHADER)); - gDeferredPostProgram.mShaderFiles.push_back(make_pair("deferred/postDeferredF.glsl", GL_FRAGMENT_SHADER)); - gDeferredPostProgram.mShaderLevel = mShaderLevel[SHADER_DEFERRED]; - gDeferredPostProgram.clearPermutations(); - gDeferredPostProgram.addPermutation("FRONT_BLUR", "1"); + // Four DoF gather variants across two orthogonal axes. Both are value + // tests in the shader, so the "off" build defines the symbol as "0" + // rather than omitting it -- omitting it would make #if FRONT_BLUR a + // compile error rather than a false branch. + struct PostVariant + { + LLGLSLShader* shader; + const char* name; + const char* front_blur; + const char* dof_shaped; + }; - success = gDeferredPostProgram.createShader(); - llassert(success); - } + const PostVariant post_variants[] = + { + { &gDeferredPostProgram, "Deferred Post Shader", "1", "0" }, + { &gDeferredPostProgramNoNear, "Deferred Post Shader No Near Blur", "0", "0" }, + { &gDeferredPostProgramShaped, "Deferred Post Shader Shaped", "1", "1" }, + { &gDeferredPostProgramNoNearShaped, "Deferred Post Shader No Near Blur Shaped", "0", "1" }, + }; - if (success) - { - gDeferredPostProgramNoNear.mName = "Deferred Post Shader No Near Blur"; - gDeferredPostProgramNoNear.mFeatures.isDeferred = true; - gDeferredPostProgramNoNear.mShaderFiles.clear(); - gDeferredPostProgramNoNear.mShaderFiles.push_back(make_pair("deferred/postDeferredNoTCV.glsl", GL_VERTEX_SHADER)); - gDeferredPostProgramNoNear.mShaderFiles.push_back(make_pair("deferred/postDeferredF.glsl", GL_FRAGMENT_SHADER)); - gDeferredPostProgramNoNear.mShaderLevel = mShaderLevel[SHADER_DEFERRED]; - gDeferredPostProgramNoNear.clearPermutations(); - gDeferredPostProgramNoNear.addPermutation("FRONT_BLUR", "0"); + for (const PostVariant& variant : post_variants) + { + variant.shader->mName = variant.name; + variant.shader->mFeatures.isDeferred = true; + variant.shader->mShaderFiles.clear(); + variant.shader->mShaderFiles.push_back(make_pair("deferred/postDeferredNoTCV.glsl", GL_VERTEX_SHADER)); + variant.shader->mShaderFiles.push_back(make_pair("deferred/postDeferredF.glsl", GL_FRAGMENT_SHADER)); + variant.shader->mShaderLevel = mShaderLevel[SHADER_DEFERRED]; + variant.shader->clearPermutations(); + variant.shader->addPermutation("FRONT_BLUR", variant.front_blur); + variant.shader->addPermutation("DOF_SHAPED", variant.dof_shaped); - success = gDeferredPostProgramNoNear.createShader(); - llassert(success); + success = variant.shader->createShader(); + llassert(success); + if (!success) + { + break; + } + } } if (success) diff --git a/indra/newview/llviewershadermgr.h b/indra/newview/llviewershadermgr.h index a017bf1fc5..87744a2277 100644 --- a/indra/newview/llviewershadermgr.h +++ b/indra/newview/llviewershadermgr.h @@ -140,6 +140,21 @@ extern LLGLSLShader gBloomDownsampleProgram; extern LLGLSLShader gBloomDownsampleFirstProgram; extern LLGLSLShader gBloomUpsampleProgram; extern LLGLSLShader gBloomCompositeProgram; +// Taps per cross-filter pass. Injected into crossFilterF.glsl as CROSS_TAPS +// and used by pipeline.cpp for the pass strides and the falloff remap, so the +// chain's exact base-N tiling has one source of truth instead of three sites +// that each hard-coded 4 or 63. +constexpr S32 CROSS_FILTER_TAPS = 4; +// Shared by the generation gate and the composite, which read the same setting +// in two different files and must agree, or the effect changes brightness +// between "is it on" and "how bright". +constexpr F32 CROSS_FILTER_MAX_STRENGTH = 32.f; +extern LLGLSLShader gCrossFilterProgram; +// Upper bound on the generator's segment loops. Injected into lensDirtGenF as +// DIRT_MAX_LINES and used by pipeline.cpp to clamp the scratch count, so the +// loop bound and the CPU clamp cannot drift apart. +constexpr S32 LENS_DIRT_MAX_LINES = 32; +extern LLGLSLShader gLensDirtGenProgram; //interface shaders extern LLGLSLShader gHighlightProgram; @@ -184,8 +199,14 @@ extern LLGLSLShader gDeferredShadowGLTFAlphaMaskIndexedProgram; // multi extern LLGLSLShader gDeferredShadowMaterialIndexedProgram; // multi-material indexed legacy mask shadow extern LLGLSLShader gDeferredShadowGLTFAlphaBlendProgram; extern LLGLSLShader gDeferredShadowFullbrightAlphaMaskProgram; +// DoF gather blur, four variants over two orthogonal compile-time axes: +// FRONT_BLUR (RenderDepthOfFieldNearBlur) and DOF_SHAPED (any of the shaped +// aperture, cat's-eye or defocus fringe being active). The shaped code sits in +// the innermost sample loop, so it is compiled out rather than branched over. extern LLGLSLShader gDeferredPostProgram; extern LLGLSLShader gDeferredPostProgramNoNear; +extern LLGLSLShader gDeferredPostProgramShaped; +extern LLGLSLShader gDeferredPostProgramNoNearShaped; extern LLGLSLShader gDeferredCoFProgram; extern LLGLSLShader gDeferredDoFCombineProgram; extern LLGLSLShader gFXAAProgram[4]; diff --git a/indra/newview/pipeline.cpp b/indra/newview/pipeline.cpp index 5fe920cf6b..9275a5c894 100644 --- a/indra/newview/pipeline.cpp +++ b/indra/newview/pipeline.cpp @@ -40,6 +40,7 @@ #include "llimagewebp.h" #include "llaudioengine.h" // For debugging. #include "llerror.h" +#include "llfocusmgr.h" #include "llviewercontrol.h" #include "llfasttimer.h" #include "llfontgl.h" @@ -927,8 +928,8 @@ bool LLPipeline::allocateScreenBufferInternal(U32 resX, U32 resY) mRT->deferredScreen.shareDepthBuffer(mRT->screen); - if (shadow_detail > 0 || ssao || RenderDepthOfField) - { //only need mRT->deferredLight for shadows OR ssao OR dof + if (shadow_detail > 0 || ssao) + { //only need mRT->deferredLight for shadows OR ssao if (!mRT->deferredLight.allocate(resX, resY, screenFormat)) return false; } else @@ -936,6 +937,33 @@ bool LLPipeline::allocateScreenBufferInternal(U32 resX, U32 resY) mRT->deferredLight.release(); } + // Depth of field scratch, owned by the DoF pass. + // + // Main pack only, written as a positive identity test so the intent is + // the code: renderDoF is gated on !gCubeSnapshot and never runs for the + // auxillary (512^2) or hero probe packs, so allocating there is pure + // waste. Note the bloom/postPing block below casts a *wider*, pre-existing + // net (it excludes only the hero probe) -- that is not the pattern to copy + // for new full-frame post targets. Released outright whenever DoF is off, + // so the feature costs no VRAM rather than merely little. + // + // DoF used to borrow deferredLight for the sharp+CoF copy. It no longer + // does: that buffer is the SSAO / sun-shadow factor every deferred lighting + // shader samples, and widening it to RGBA16F for the non-HDR case would + // have doubled a frame-wide bandwidth cost to serve one pass at the end of + // the frame. Dropping RenderDepthOfField from the condition above also + // hands ~15-30 MB back to anyone running DoF with shadows and SSAO off. + if (RenderDepthOfField && mRT == &mMainRT) + { + if (!mRT->dofSharp.allocate(resX, resY, GL_RGBA16F)) return false; + if (!mRT->dofBlur.allocate(resX, resY, GL_R11F_G11F_B10F)) return false; + } + else + { + mRT->dofSharp.release(); + mRT->dofBlur.release(); + } + U32 post_color_fmt = hdr ? GL_RGB10_A2 : GL_RGBA8; if(mRT != &mHeroProbeRT) { @@ -1284,6 +1312,12 @@ void LLPipeline::releaseGLBuffers() mSMAASearchMap = 0; } + // Clearing the recorded parameters matters as much as the release: they are + // what generateLensDirt compares against, so leaving them set would make it + // decide nothing had changed and skip rebuilding the plate it no longer has. + mLensDirtMap.release(); + mLensDirtParams = LensDirtParams(); + releaseLUTBuffers(); mWaterDis.release(); @@ -1365,11 +1399,18 @@ void LLPipeline::releaseScreenBuffers() rt.deferredLight.release(); rt.postPingMap.release(); rt.postPongMap.release(); + rt.dofSharp.release(); + rt.dofBlur.release(); for (U32 i = 0; i < BLOOM_MAX_MIPS; i++) { rt.bloomMip[i].release(); } rt.bloomMipCount = 0; + for (U32 i = 0; i < 3; ++i) + { + rt.crossFilter[i].release(); + } + rt.crossFilterHeight = 0; }; release_pack(mMainRT); release_pack(mAuxillaryRT); @@ -1591,6 +1632,149 @@ void LLPipeline::createLUTBuffers() mLastExposure.allocate(1, 1, GL_R16F); } +// Lens dirt plate generator. +// +// The plate used to be one of four bundled images picked from a list. Generating +// it makes it resolution-independent -- built at the frame's own aspect, so a +// mote stays round on an ultrawide without the cover-fit the square plates +// needed -- and puts the grime itself on sliders instead of shipping fixed looks +// and hoping one fits the shot. +// +// This is not a per-frame pass. It runs when a parameter moves or the window +// resizes, and that budget is what lets the shader afford four cellular layers, +// two fBm fields and up to LENS_DIRT_MAX_LINES segment distance fields per +// pixel. The per-frame cost of the effect is still the one texture fetch in +// colorCorrect. +void LLPipeline::generateLensDirt() +{ + static LLCachedControl dirt_strength(gSavedSettings, "RenderLensDirtStrength", 0.f); + static LLCachedControl dirt_seed(gSavedSettings, "RenderLensDirtSeed", 7); + static LLCachedControl dirt_grime(gSavedSettings, "RenderLensDirtGrime", 1.f); + static LLCachedControl dirt_mote_scale(gSavedSettings, "RenderLensDirtMoteScale", 1.f); + static LLCachedControl dirt_smudge(gSavedSettings, "RenderLensDirtSmudge", 1.f); + static LLCachedControl dirt_scratches(gSavedSettings, "RenderLensDirtScratches", 0); + static LLCachedControl dirt_toe(gSavedSettings, "RenderLensDirtToe", 1.6f); + static LLCachedControl dirt_gain(gSavedSettings, "RenderLensDirtGain", 1.f); + + // Deliberately not gated on gSnapshotNoPost. That flag is true for the one + // frame a no-post snapshot is taken, so releasing the plate for it would + // buy a full regeneration on the very next frame -- a hitch every time + // someone takes one. colorCorrect already forces the strength uniform to 0 + // under a clean plate, which is the gate that actually matters. + const bool dirt_on = (dirt_strength() > 0.f) && gLensDirtGenProgram.isComplete(); + + if (!dirt_on) + { + // Release rather than merely skip, the same way the cross filter does: + // allocating lazily is only worth anything if switching the effect off + // gives the memory back. Doing it here rather than from a commit signal + // keeps one teardown path, inside the render loop where the target is + // owned, and lets the strength control stay a live slider -- wiring a + // slider to a reallocation handler would fire on every mouse-move. + if (mLensDirtMap.isComplete() || mLensDirtParams != LensDirtParams()) + { + mLensDirtMap.release(); + mLensDirtParams = LensDirtParams(); + } + return; + } + + // The frame's own resolution. There is no fitting to do when the plate is + // made at the shape it will be read at, and at GL_R8 even a 4K plate is + // about 8 MB -- cheap for something only allocated while the effect is on. + // + // Generating at full resolution is only affordable because the rebuild is + // debounced below. Capping the plate instead would bound the cost of one + // rebuild but not the number of them, which is the part that hurts. + const U32 gen_w = llmax(1u, mRT->screen.getWidth()); + const U32 gen_h = llmax(1u, mRT->screen.getHeight()); + + LensDirtParams want; + want.width = gen_w; + want.height = gen_h; + want.seed = (F32)llclamp(dirt_seed(), 0, 999); + want.grime = llclamp(dirt_grime(), 0.f, 2.f); + want.mote_scale = llclamp(dirt_mote_scale(), 0.5f, 2.f); + want.smudge = llclamp(dirt_smudge(), 0.f, 2.f); + want.scratches = llclamp(dirt_scratches(), 0, LENS_DIRT_MAX_LINES); + want.toe = llclamp(dirt_toe(), 0.6f, 4.f); + want.gain = llclamp(dirt_gain(), 0.5f, 2.5f); + + // Compared before the target is inspected, so a plate that failed to + // allocate is not retried -- and this warning not repeated -- every frame + // while VRAM stays exhausted. The next attempt happens when something + // actually moves, and the bind site keeps the effect off through + // isComplete() until one succeeds. + if (want == mLensDirtParams) + { + return; + } + + // Hold off while a generation slider is being dragged. A drag changes a + // parameter every frame, and at full resolution rebuilding on each one is a + // stutter rather than a preview -- the slower the machine, the more of the + // drag it stutters through, which is backwards. The rebuild instead lands + // once, on release, which is where the result is being looked for anyway. + // + // Only a drag is held off, which is the whole reason this is a UI signal + // rather than a settle timer: a typed value, a reset button, applying a + // Look, undo, and a window resize all arrive here with no slider down and + // rebuild on the spot, where a timer would have made every one of them wait + // for no reason. The first plate is never held off either -- until one + // exists the effect is simply absent, and a pause reads as a bug. + // + // The capture test is a failsafe rather than part of the logic. LLSlider + // raises the flag from handleMouseDown and lowers it from handleMouseUp, + // but it implements no onMouseCaptureLost, so a capture stolen mid-drag + // would otherwise leave the flag stuck and the plate frozen until something + // else moved. No captor means no drag, whatever the flag says. + if (mLensDirtSliderHeld && gFocusMgr.getMouseCapture() != nullptr) + { + return; + } + + mLensDirtParams = want; + + // Everything above is a comparison; the zone starts where the work does. + LL_PROFILE_GPU_ZONE("lens dirt generate"); + + if (mLensDirtMap.getWidth() != gen_w || mLensDirtMap.getHeight() != gen_h) + { + mLensDirtMap.release(); + if (!mLensDirtMap.allocate(gen_w, gen_h, GL_R8)) + { + LL_WARNS() << "Could not allocate the lens dirt plate; effect disabled until the parameters change" << LL_ENDL; + return; + } + LL_DEBUGS("Pipeline") << "Lens dirt plate at " << gen_w << "x" << gen_h << LL_ENDL; + } + + gLensDirtGenProgram.bind(); + + gLensDirtGenProgram.uniform2f(LLShaderMgr::LENS_DIRT_RESOLUTION, (F32)gen_w, (F32)gen_h); + gLensDirtGenProgram.uniform1f(LLShaderMgr::LENS_DIRT_SEED, want.seed); + gLensDirtGenProgram.uniform1f(LLShaderMgr::LENS_DIRT_GRIME, want.grime); + gLensDirtGenProgram.uniform1f(LLShaderMgr::LENS_DIRT_MOTE_SCALE, want.mote_scale); + gLensDirtGenProgram.uniform1f(LLShaderMgr::LENS_DIRT_SMUDGE, want.smudge); + gLensDirtGenProgram.uniform1i(LLShaderMgr::LENS_DIRT_SCRATCHES, want.scratches); + gLensDirtGenProgram.uniform1f(LLShaderMgr::LENS_DIRT_TOE, want.toe); + gLensDirtGenProgram.uniform1f(LLShaderMgr::LENS_DIRT_GAIN, want.gain); + + // No clear: the fullscreen triangle writes every texel with blending off, + // so clearing first would be pure redundant fill. + { + LLGLDisable blend(GL_BLEND); + + mLensDirtMap.bindTarget(); + mScreenTriangleVB->setBuffer(); + mScreenTriangleVB->drawArrays(LLRender::TRIANGLES, 0, 3); + mLensDirtMap.flush(); + } + + gLensDirtGenProgram.unbind(); + stop_glerror(); +} + void LLPipeline::setupGradingLUT() { mCGLut = nullptr; @@ -7812,6 +7996,7 @@ void LLPipeline::colorCorrect(LLRenderTarget* src, LLRenderTarget* dst, bool app // the composite no longer needs its own pass. When HDR is off the shader // variant lacks the sampler and bindTexture is a no-op via getTextureChannel. S32 bloom_channel = -1; + S32 cross_channel = -1; if (mRT->bloomMipCount > 0) { bloom_channel = shader->bindTexture(LLShaderMgr::BLOOM_SAMPLER, &mRT->bloomMip[0], ALSamplers::BilinearMirror); @@ -7828,6 +8013,31 @@ void LLPipeline::colorCorrect(LLRenderTarget* src, LLRenderTarget* dst, bool app shader->uniform1f(LLShaderMgr::HALATION_STRENGTH, llmax(halation_strength(), 0.0f) * strength_gate); const LLColor3& tint = halation_tint(); shader->uniform3f(LLShaderMgr::HALATION_TINT, tint.mV[0], tint.mV[1], tint.mV[2]); + + // Cross-filter streaks fold in here rather than in a fullscreen + // pass of their own. That pass existed only to add a half-size + // buffer into bloomMip[0], which cost a full-resolution + // read-modify-write of the pyramid top every frame; this pass + // already samples that pyramid, so one more sampler replaces all + // of it. + // + // Added to bloom_term inside the shader rather than to the scene + // directly, which keeps two couplings that were previously free: + // the streaks stay scaled by bloom strength, and they keep + // lighting the lens dirt through lens_light. + static LLCachedControl streak_strength_setting(gSavedSettings, "RenderCrossFilterStrength", 0.f); + const bool streaks_live = (mRT->crossFilterHeight != 0) + && mRT->crossFilter[2].isComplete(); + const F32 streaks = (streaks_live && !gSnapshotNoPost) + ? llclamp(streak_strength_setting(), 0.f, CROSS_FILTER_MAX_STRENGTH) * strength_gate + : 0.f; + shader->uniform1f(LLShaderMgr::CROSS_STRENGTH, streaks); + if (streaks > 0.f) + { + cross_channel = shader->bindTexture(LLShaderMgr::CROSS_FILTER_MAP, + &mRT->crossFilter[2], + ALSamplers::BilinearClamp); + } } } @@ -7977,6 +8187,35 @@ void LLPipeline::colorCorrect(LLRenderTarget* src, LLRenderTarget* dst, bool app } } + // Lens dirt + // + // Forced off whenever there is no plate -- generateLensDirt allocates + // only while the effect is on, and gives the memory back when it is + // not -- so the shader's early-out fires and the sampler is never read + // unbound, the same guard the grading LUT and the reference still use. + // Also off under a clean plate: dirt is a look, not a quantisation aid. + S32 dirt_channel = -1; + { + static LLCachedControl lens_dirt_strength(gSavedSettings, "RenderLensDirtStrength", 0.f); + static LLCachedControl lens_dirt_bloom(gSavedSettings, "RenderLensDirtBloomResponse", 1.f); + static LLCachedControl lens_dirt_flare(gSavedSettings, "RenderLensDirtFlareResponse", 1.f); + + const F32 dirt_strength = (clean_plate || !mLensDirtMap.isComplete()) + ? 0.f + : llclamp(lens_dirt_strength(), 0.f, 2.f); + + shader->uniform1f(LLShaderMgr::LENS_DIRT_STRENGTH, dirt_strength); + shader->uniform1f(LLShaderMgr::LENS_DIRT_BLOOM_RESPONSE, llclamp(lens_dirt_bloom(), 0.f, 2.f)); + shader->uniform1f(LLShaderMgr::LENS_DIRT_FLARE_RESPONSE, llclamp(lens_dirt_flare(), 0.f, 2.f)); + + if (dirt_strength > 0.f) + { + dirt_channel = shader->bindTexture(LLShaderMgr::LENS_DIRT_MAP, + &mLensDirtMap, + ALSamplers::BilinearClamp); + } + } + if (apply_tonemap) { // Exposure parameters @@ -8243,10 +8482,18 @@ void LLPipeline::colorCorrect(LLRenderTarget* src, LLRenderTarget* dst, bool app { mCGLut->unbind(cglut_channel); } + if (dirt_channel > -1) + { + gGL.getTextureSlot(dirt_channel)->unbind(); + } if (exposure_channel > -1) { gGL.getTextureSlot(exposure_channel)->unbind(); } + if (cross_channel > -1) + { + gGL.getTextureSlot(cross_channel)->unbind(); + } if (bloom_channel > -1) { gGL.getTextureSlot(bloom_channel)->unbind(); @@ -8472,6 +8719,240 @@ void LLPipeline::generateBloomHDR(LLRenderTarget* src) } } + // ---- Cross-screen (star) filter --------------------------------------- + // + // Streaks every thresholded highlight, the way an etched glass filter + // diffracts any bright point in frame. Distinct from the lens flare + // starburst, which is locked to the sun and drawn procedurally around it. + // + // Split across the upsample chain on purpose: the streak input has to be + // read *before* the upsample walk mutates the mips, but the result has to + // be added *after* it, or the walk would smear the streaks back through + // the pyramid. + static LLCachedControl streak_strength_setting(gSavedSettings, "RenderCrossFilterStrength", 0.f); + static LLCachedControl cross_points(gSavedSettings, "RenderCrossFilterPoints", 4); + static LLCachedControl cross_angle(gSavedSettings, "RenderCrossFilterAngle", 0.f); + static LLCachedControl cross_length(gSavedSettings, "RenderCrossFilterLength", 1.f); + static LLCachedControl cross_falloff(gSavedSettings, "RenderCrossFilterFalloff", 1.5f); + static LLCachedControl cross_chromatic(gSavedSettings, "RenderCrossFilterChromatic", 0.f); + + // Streaks ride the bloom pyramid and are scaled by bloom strength where + // they are composited, so at strength 0 they are invisible -- and the whole + // twelve-draw chain was still running to produce them. Folding the bloom + // strength into the gate reuses the release path below rather than adding a + // second one. + // + // Only the streaks, not the pyramid: generateLuminance binds bloomMip[0] as + // the emissive term for auto-exposure, so skipping the pyramid would meter + // the scene against a stale buffer. + static LLCachedControl bloom_strength_gate(gSavedSettings, "RenderBloomStrength", 0.325f); + + const F32 streak_strength = (no_post || bloom_strength_gate() <= 0.f) + ? 0.f + : llclamp(streak_strength_setting(), 0.f, CROSS_FILTER_MAX_STRENGTH); + const bool streaks_on = (streak_strength > 0.f) && gCrossFilterProgram.isComplete(); + bool streaks_ready = false; + + if (!streaks_on) + { + // Release rather than merely skip. Allocating lazily is only worth + // anything if switching the effect off gives the memory back, and doing + // it here rather than from a settings commit signal keeps one teardown + // path, inside the render loop, where the targets are owned. It also + // lets the strength control stay a live slider: wiring a slider to a + // reallocation handler would fire on every mouse-move. + if (mRT->crossFilterHeight != 0) + { + for (U32 i = 0; i < 3; ++i) + { + mRT->crossFilter[i].release(); + } + mRT->crossFilterHeight = 0; + } + } + else + { + // Streak from mip 0, which at this point in the pass still holds the + // raw thresholded extract: the downsample chain writes mips 1 and up + // and leaves mip 0 untouched, so it is the sharpest and cleanest + // "which pixels are bright" answer available. + // + // This used to pick a half-resolution mip to save fill. That made the + // arms visibly fat, and not merely because of the upscale: mip 1 is a + // 13-tap downsample, so the highlight being streaked had already been + // smeared into a blob before the streak ever started, and a streak can + // be no thinner than the point it is drawn from. + // + // Cost scales with RenderBloomResolutionScale, which sizes the whole + // pyramid -- lowering it makes the streaks cheaper and softer together. + // + // Twelve passes at four arms is a lot of fill at full resolution, and + // quartering the area is the cheapest lever that does not touch arm + // count or reach. The source stays mip 0, so the *point* being streaked + // is still the sharp extract rather than a pre-blurred mip -- what is + // lost is arm resolution, not arm origin. + // + // The first pass gets a proper box downsample for free: a half-res texel + // centre lands exactly on the corner between two full-res texels, so the + // bilinear fetch averages the 2x2 group rather than point-sampling it. + const U32 streak_w = llmax(1u, mRT->bloomMip[0].getWidth() / 2); + const U32 streak_h = llmax(1u, mRT->bloomMip[0].getHeight() / 2); + + if (mRT->crossFilterHeight != streak_h) + { + for (U32 i = 0; i < 3; ++i) + { + mRT->crossFilter[i].release(); + } + + // Three targets, not two: each arm needs its own ping-pong chain, + // and the arms have to accumulate somewhere that is neither the + // chain's scratch nor its source. Accumulating straight into + // bloomMip[0] would work for the first arm and then feed the second + // arm its own output. + // + // No alpha on any of them: streaks carry no halation payload. + bool ok = true; + for (U32 i = 0; i < 3 && ok; ++i) + { + ok = mRT->crossFilter[i].allocate(streak_w, streak_h, GL_R11F_G11F_B10F); + } + + if (ok) + { + mRT->crossFilterHeight = streak_h; + LL_DEBUGS("Pipeline") << "Cross filter streaking at " << streak_w << "x" << streak_h << LL_ENDL; + } + else + { + for (U32 i = 0; i < 3; ++i) + { + mRT->crossFilter[i].release(); + } + // Latch the failure by recording the size anyway. Zeroing the + // height here made the allocation retry -- and this warning + // repeat -- every frame while VRAM stayed exhausted, exactly + // when per-frame GL allocation churn hurts most. Recording the + // attempted size means the next retry happens only when the + // size changes (resize, bloom scale) or the effect is toggled, + // and streaks_ready below stays false through isComplete(). + mRT->crossFilterHeight = streak_h; + LL_WARNS() << "Could not allocate cross filter targets; effect disabled until the size changes" << LL_ENDL; + } + } + + // isComplete() distinguishes "built at this size" from "failed at this + // size" -- crossFilterHeight alone can no longer tell them apart. + streaks_ready = (mRT->crossFilterHeight == streak_h) && mRT->crossFilter[2].isComplete(); + + if (streaks_ready) + { + const F32 angle_rad = llclamp(cross_angle(), 0.f, 360.f) * DEG_TO_RAD; + const S32 arms = llclamp(cross_points(), 2, 12); + + gCrossFilterProgram.bind(); + + // Base step in texels of the streak target, near 1 by design: it + // multiplies every offset, so at 2 the chain lands on even texels + // only and real gaps open between them. Reach comes from the three + // quadrupling passes (0..63 texels), not from scaling this up. + gCrossFilterProgram.uniform1f(LLShaderMgr::CROSS_LENGTH, llclamp(cross_length(), 0.25f, 2.f)); + + // Falloff is authored as a 0..1.5 tightness and converted here to + // the exponential base the shader wants. + // + // Exposing that base directly was a mistake. Weights are + // pow(base, -step_index) and step_index reaches 63 across the + // chain, so base 1.5 attenuates the far taps by 1e-11 -- the arms + // simply vanished -- and everything usable lived between 1.0 and + // roughly 1.1. Well over nine tenths of the shipped range did + // nothing but turn the effect off. This maps the whole slider onto + // that band: the value is how many e-folds of brightness are lost + // between the core and the tip of an arm, over six. + const F32 tightness = llclamp(cross_falloff(), 0.1f, 3.f); + // The chain's exact reach, TAPS^3 - 1, derived from the same constant + // the shader compiles against -- see CROSS_FILTER_TAPS. + const F32 max_step = (F32)(CROSS_FILTER_TAPS * CROSS_FILTER_TAPS * CROSS_FILTER_TAPS - 1); + gCrossFilterProgram.uniform1f(LLShaderMgr::CROSS_FALLOFF, expf(tightness * 6.f / max_step)); + gCrossFilterProgram.uniform1f(LLShaderMgr::CROSS_CHROMATIC, llclamp(cross_chromatic(), 0.f, 1.f)); + + // One three-pass chain per arm, each strictly one-sided. + // + // Streaking every direction in a single pass is what produced the + // spikes: a tap could run forward in one pass and backward in the + // next, so net offsets became +/-i +/-4j +/-16k with independent + // signs and their weights tracked how far the path travelled rather + // than where it ended. Per-arm chains restore the base-4 tiling the + // whole construction depends on. + for (S32 arm = 0; arm < arms; ++arm) + { + const F32 theta = angle_rad + (2.f * F_PI * (F32)arm) / (F32)arms; + const F32 dir_x = cosf(theta); + const F32 dir_y = sinf(theta); + gCrossFilterProgram.uniform2f(LLShaderMgr::CROSS_DIR, dir_x, dir_y); + + LLRenderTarget* sources[3] = { &mRT->bloomMip[0], &mRT->crossFilter[0], &mRT->crossFilter[1] }; + LLRenderTarget* dests[3] = { &mRT->crossFilter[0], &mRT->crossFilter[1], &mRT->crossFilter[2] }; + const F32 scales[3] = { 1.f, (F32)CROSS_FILTER_TAPS, + (F32)(CROSS_FILTER_TAPS * CROSS_FILTER_TAPS) }; + + auto streak_pass = [&](S32 pass) + { + LLRenderTarget* src = sources[pass]; + + gCrossFilterProgram.bindTexture(LLShaderMgr::DIFFUSE_MAP, src, ALSamplers::BilinearClamp); + // Always the *streak target's* texel, never the source's. + // The base-4 tiling only holds if every pass steps in the + // same unit, and pass 0 reads a full-resolution mip while + // the rest read half-resolution scratch -- using each + // source's own texel would double the stride midway through + // the chain and break the tiling that the whole + // construction depends on. + gCrossFilterProgram.uniform2f(LLShaderMgr::CROSS_TEXEL, + 1.f / (F32)streak_w, + 1.f / (F32)streak_h); + gCrossFilterProgram.uniform1f(LLShaderMgr::CROSS_PASS_SCALE, scales[pass]); + + mScreenTriangleVB->setBuffer(); + mScreenTriangleVB->drawArrays(LLRender::TRIANGLES, 0, 3); + }; + + // Two scratch passes overwrite; blending stays off. No clear: + // the fullscreen triangle writes every texel with blending + // disabled, so a clear would be pure redundant fill. + { + LLGLDisable blend(GL_BLEND); + for (S32 pass = 0; pass < 2; ++pass) + { + dests[pass]->bindTarget(); + streak_pass(pass); + dests[pass]->flush(); + } + } + + // The arm's last pass adds into the shared accumulator -- except + // the first, which overwrites it. The fullscreen triangle covers + // every texel, so arm 0 establishes the buffer and the clear this + // used to need was the same redundant fill the scratch passes + // already avoid. It does couple correctness to the first + // iteration running, which holds because `arms` is clamped to at + // least 2 above. + { + LLGLState blend(GL_BLEND, arm > 0); + gGL.setSceneBlendType(LLRender::BT_ADD); + + dests[2]->bindTarget(); + streak_pass(2); + dests[2]->flush(); + + gGL.setSceneBlendType(LLRender::BT_ALPHA); + } + } + + gCrossFilterProgram.unbind(); + } + } + // Upsample chain: mip[i] -> mip[i-1] with additive blend. Walks from the // smallest mip back up to mip 0, leaving the final bloom in mBloomMip[0]. { @@ -8502,12 +8983,18 @@ void LLPipeline::generateBloomHDR(LLRenderTarget* src) gBloomUpsampleProgram.unbind(); gGL.setSceneBlendType(LLRender::BT_ALPHA); } + + // The summed arms stay in crossFilter[2]. colorCorrect samples them + // alongside the pyramid, so there is no composite pass here to write them + // into mip 0. } // Composite the bloom pyramid (mBloomMip[0]) additively into the pre-tonemap // scene buffer. Halation rides in the alpha channel and is tinted at composite. -// The main render path folds this into colorCorrectF (BLOOM_COMPOSITE); this -// function is retained for standalone use (e.g. offline capture paths). +// The main render path folds this into colorCorrectF (BLOOM_COMPOSITE). This +// function has never had a caller anywhere in the tree; it is kept as a +// standalone equivalent, but note it is no longer equivalent -- cross-filter +// streaks are composited only in colorCorrectF, so this path would drop them. void LLPipeline::compositeBloomHDR(LLRenderTarget* scene) { LL_PROFILE_GPU_ZONE("bloom hdr composite"); @@ -8900,7 +9387,22 @@ void LLPipeline::combineGlow(LLRenderTarget* src, LLRenderTarget* dst) dst->flush(); } -void LLPipeline::renderDoF(LLRenderTarget* src, LLRenderTarget* dst) +// Depth of field, run pre-tonemap on linear HDR and in place on mRT->screen. +// +// The three passes are: CoF (sharp copy + signed circle-of-confusion packed +// into alpha), a reduced-resolution gather blur, then a combine that mixes the +// two by CoF. The combine writes back into mRT->screen under +// setColorMask(true, false). +// +// That mask is the whole reason this can run before the tonemapper without +// touching a shader. mRT->screen.a carries the legacy alpha-tagged prim glow, +// which bloomExtractF reads in the HDR path and -- after colorCorrect passes +// alpha straight through -- glowExtractF reads as its *only* live key in the +// non-HDR path. dofCombineF's alpha output is CoF-flavoured garbage, so letting +// it land would feed circle-of-confusion into the glow key on every frame. +// Masking alpha off preserves prim glow exactly and leaves every alpha contract +// in the chain unchanged. +void LLPipeline::renderDoF() { LL_PROFILE_GPU_ZONE("dof"); { @@ -9026,17 +9528,17 @@ void LLPipeline::renderDoF(LLRenderTarget* src, LLRenderTarget* dst) blur_constant /= 1000.f; // convert to meters for shader F32 magnification = focal_length / (subject_distance - focal_length); - { // build diffuse+bloom+CoF - mRT->deferredLight.bindTarget(); + { // build sharp copy + CoF + mRT->dofSharp.bindTarget(); gDeferredCoFProgram.bind(); - gDeferredCoFProgram.bindTexture(LLShaderMgr::DEFERRED_DIFFUSE, src, ALSamplers::PointMirror); + gDeferredCoFProgram.bindTexture(LLShaderMgr::DEFERRED_DIFFUSE, &mRT->screen, ALSamplers::PointMirror); gDeferredCoFProgram.bindDepthTexture(LLShaderMgr::DEFERRED_DEPTH, &mRT->deferredScreen); gDeferredCoFProgram.uniform1f(LLShaderMgr::DEFERRED_DEPTH_CUTOFF, RenderEdgeDepthCutoff); gDeferredCoFProgram.uniform1f(LLShaderMgr::DEFERRED_NORM_CUTOFF, RenderEdgeNormCutoff); - gDeferredCoFProgram.uniform2f(LLShaderMgr::DEFERRED_SCREEN_RES, (GLfloat)dst->getWidth(), (GLfloat)dst->getHeight()); + gDeferredCoFProgram.uniform2f(LLShaderMgr::DEFERRED_SCREEN_RES, (GLfloat)mRT->screen.getWidth(), (GLfloat)mRT->screen.getHeight()); gDeferredCoFProgram.uniform1f(LLShaderMgr::DOF_FOCAL_DISTANCE, -subject_distance / 1000.f); gDeferredCoFProgram.uniform1f(LLShaderMgr::DOF_BLUR_CONSTANT, blur_constant); gDeferredCoFProgram.uniform1f(LLShaderMgr::DOF_TAN_PIXEL_ANGLE, tanf(1.f / LLDrawable::sCurPixelAngle)); @@ -9047,64 +9549,165 @@ void LLPipeline::renderDoF(LLRenderTarget* src, LLRenderTarget* dst) mScreenTriangleVB->setBuffer(); mScreenTriangleVB->drawArrays(LLRender::TRIANGLES, 0, 3); gDeferredCoFProgram.unbind(); - mRT->deferredLight.flush(); + mRT->dofSharp.flush(); } U32 dof_width = (U32)(mRT->screen.getWidth() * CameraDoFResScale); U32 dof_height = (U32)(mRT->screen.getHeight() * CameraDoFResScale); - { // perform DoF sampling at half-res (preserve alpha channel) - src->bindTarget(); + { // gather blur at CameraDoFResScale into dedicated scratch + // Writes to its own target now rather than in place, so the + // alpha-preserving colour mask this pass used to need is gone: + // the CoF it reads still lives in dofSharp.a, untouched. + mRT->dofBlur.bindTarget(); glViewport(0, 0, dof_width, dof_height); - gGL.setColorMask(true, false); - static LLCachedControl RenderDepthOfFieldNearBlur(gSavedSettings, "RenderDepthOfFieldNearBlur", false); - LLGLSLShader& post_program = RenderDepthOfFieldNearBlur ? gDeferredPostProgram : gDeferredPostProgramNoNear; + + // Shaped aperture, anamorphic deformation, optical vignetting, + // defocus fringing and the two aberrations all live in the + // innermost sample loop, + // so they are compiled out rather than branched over. One + // define covers them all: the shaped variant is bound only + // when at least one is actually doing something, and within it + // each gates on its own uniform the way the lens flare's + // sub-effects do. `shaped` below must stay in lockstep with + // the effects inside the shader's DOF_SHAPED block -- an + // effect missing from it is a dead control whenever it is the + // only one active. + static LLCachedControl bokeh_blades(gSavedSettings, "RenderBokehApertureBlades", 0); + static LLCachedControl bokeh_rotation(gSavedSettings, "RenderBokehApertureRotation", 0.f); + static LLCachedControl bokeh_curvature(gSavedSettings, "RenderBokehApertureCurvature", 0.f); + static LLCachedControl bokeh_anamorphic(gSavedSettings, "RenderBokehAnamorphicSqueeze", 1.f); + static LLCachedControl bokeh_cat_eye(gSavedSettings, "RenderBokehCatEyeAmount", 0.f); + static LLCachedControl bokeh_fringe(gSavedSettings, "RenderBokehFringeAmount", 0.f); + static LLCachedControl bokeh_fringe_near(gSavedSettings, "RenderBokehFringeNearTint", LLColor3(1.f, 0.85f, 1.f)); + static LLCachedControl bokeh_fringe_far(gSavedSettings, "RenderBokehFringeFarTint", LLColor3(0.85f, 1.f, 0.9f)); + static LLCachedControl bokeh_spherical(gSavedSettings, "RenderBokehSphericalAberration", 0.f); + static LLCachedControl bokeh_field(gSavedSettings, "RenderBokehFieldStretch", 0.f); + static LLCachedControl bokeh_field_falloff(gSavedSettings, "RenderBokehFieldFalloff", 2.f); + static LLCachedControl bokeh_coma(gSavedSettings, "RenderBokehComaAsymmetry", 0.f); + + const S32 blades = llclamp(bokeh_blades(), 0, 11); + const F32 cat_eye = llclamp(bokeh_cat_eye(), 0.f, 1.5f); + const F32 fringe = llclamp(bokeh_fringe(), 0.f, 1.f); + const F32 squeeze = llclamp(bokeh_anamorphic(), 0.25f, 4.f); + const F32 spherical = llclamp(bokeh_spherical(), -1.f, 1.f); + const F32 field = llclamp(bokeh_field(), -1.f, 1.f); + const F32 coma = llclamp(bokeh_coma(), 0.f, 1.f); + const bool anamorphic = (squeeze < 0.999f) || (squeeze > 1.001f); + // Comatic asymmetry earns its place here even though it reads + // like a modifier: it biases the disc along the field + // direction, which exists whether or not anything stretched + // it, so it is a standalone effect rather than a shape control + // for the stretch. RenderBokehFieldFalloff genuinely is one and + // is deliberately absent. + const bool shaped = (blades >= 3) || (cat_eye > 0.f) || (fringe > 0.f) || anamorphic + || (spherical != 0.f) || (field != 0.f) || (coma > 0.f); + + LLGLSLShader& post_program = RenderDepthOfFieldNearBlur + ? (shaped ? gDeferredPostProgramShaped : gDeferredPostProgram) + : (shaped ? gDeferredPostProgramNoNearShaped : gDeferredPostProgramNoNear); post_program.bind(); - post_program.bindTexture(LLShaderMgr::DEFERRED_DIFFUSE, &mRT->deferredLight, ALSamplers::PointMirror); + post_program.bindTexture(LLShaderMgr::DEFERRED_DIFFUSE, &mRT->dofSharp, ALSamplers::PointMirror); - post_program.uniform2f(LLShaderMgr::DEFERRED_SCREEN_RES, (GLfloat)dst->getWidth(), (GLfloat)dst->getHeight()); + post_program.uniform2f(LLShaderMgr::DEFERRED_SCREEN_RES, (GLfloat)mRT->screen.getWidth(), (GLfloat)mRT->screen.getHeight()); post_program.uniform1f(LLShaderMgr::DOF_MAX_COF, CameraMaxCoF); - post_program.uniform1f(LLShaderMgr::DOF_RES_SCALE, CameraDoFResScale); + + // Gather weighting. Defaults are a plain energy-conserving + // average plus a firefly ceiling; the highlight boost is + // opt-in. See the note above dofSample for why the old + // `0.25 + r+g+b` weight could not survive the move to linear. + static LLCachedControl bokeh_threshold(gSavedSettings, "RenderBokehHighlightThreshold", 0.f); + static LLCachedControl bokeh_gain(gSavedSettings, "RenderBokehHighlightGain", 0.f); + static LLCachedControl bokeh_clamp(gSavedSettings, "RenderBokehHighlightClamp", 64.f); + post_program.uniform1f(LLShaderMgr::BOKEH_HIGHLIGHT_THRESHOLD, llmax(bokeh_threshold(), 0.f)); + post_program.uniform1f(LLShaderMgr::BOKEH_HIGHLIGHT_GAIN, llmax(bokeh_gain(), 0.f)); + post_program.uniform1f(LLShaderMgr::BOKEH_HIGHLIGHT_CLAMP, llmax(bokeh_clamp(), 0.f)); + + // Shaped-aperture uniforms. Skipped entirely for the unshaped + // programs, where they are not in the linked binary anyway -- + // the setters would no-op, but the sector bake would still run. + if (shaped) + { + // A regular N-gon's inscribed radius at angle theta is + // cos(pi/N) / cos(mod(theta + rot, 2pi/N) - pi/N) + // so the sector geometry is baked once here and the loop is + // left with the single cosine it genuinely needs per sample. + const F32 sides = (F32)llmax(blades, 3); + const F32 half_sector = F_PI / sides; + post_program.uniform1i(LLShaderMgr::BOKEH_BLADES, blades); + post_program.uniform1f(LLShaderMgr::BOKEH_APERTURE_ROTATION, + llclamp(bokeh_rotation(), 0.f, 360.f) * DEG_TO_RAD); + post_program.uniform1f(LLShaderMgr::BOKEH_APERTURE_CURVATURE, llclamp(bokeh_curvature(), 0.f, 1.f)); + post_program.uniform3f(LLShaderMgr::BOKEH_APERTURE_CONST, + half_sector, 2.f * half_sector, cosf(half_sector)); + // Anamorphic stretch, sent area-preserving: the two axes + // multiply to 1, so the slider changes the shape of the + // blur without also changing how much of it there is. + // Above 1 is taller than wide, the classic anamorphic oval; + // below 1 is wider than tall. + const F32 anam_root = sqrtf(squeeze); + post_program.uniform2f(LLShaderMgr::BOKEH_ANAMORPHIC, 1.f / anam_root, anam_root); + post_program.uniform1f(LLShaderMgr::BOKEH_CAT_EYE, cat_eye); + post_program.uniform1f(LLShaderMgr::BOKEH_FRINGE_AMOUNT, fringe); + post_program.uniform3fv(LLShaderMgr::BOKEH_FRINGE_NEAR_TINT, 1, bokeh_fringe_near().mV); + post_program.uniform3fv(LLShaderMgr::BOKEH_FRINGE_FAR_TINT, 1, bokeh_fringe_far().mV); + + // Aberrations. Spherical goes up raw: the shader folds in + // both the sign of the circle of confusion and the + // blur-size fade, because both depend on the fragment + // rather than on the frame. + post_program.uniform1f(LLShaderMgr::BOKEH_SPHERICAL, spherical); + post_program.uniform1f(LLShaderMgr::BOKEH_FIELD_STRETCH, field); + post_program.uniform1f(LLShaderMgr::BOKEH_FIELD_FALLOFF, + llclamp(bokeh_field_falloff(), 1.f, 4.f)); + post_program.uniform1f(LLShaderMgr::BOKEH_COMA_ASYMMETRY, coma); + } mScreenTriangleVB->setBuffer(); mScreenTriangleVB->drawArrays(LLRender::TRIANGLES, 0, 3); post_program.unbind(); - src->flush(); - gGL.setColorMask(true, true); + mRT->dofBlur.flush(); } - { // combine result based on alpha + { // combine result based on alpha, back into the scene buffer + mRT->screen.bindTarget(); + glViewport(0, 0, mRT->screen.getWidth(), mRT->screen.getHeight()); - dst->bindTarget(); - glViewport(0, 0, dst->getWidth(), dst->getHeight()); + // Colour only. See the note above renderDoF: screen.a is the + // prim-glow tag, and dofCombineF's alpha is CoF garbage. + gGL.setColorMask(true, false); gDeferredDoFCombineProgram.bind(); - gDeferredDoFCombineProgram.bindTexture(LLShaderMgr::DEFERRED_DIFFUSE, src, ALSamplers::PointMirror); - gDeferredDoFCombineProgram.bindTexture(LLShaderMgr::DEFERRED_LIGHT, &mRT->deferredLight, ALSamplers::PointMirror); + gDeferredDoFCombineProgram.bindTexture(LLShaderMgr::DEFERRED_DIFFUSE, &mRT->dofBlur, ALSamplers::PointMirror); + gDeferredDoFCombineProgram.bindTexture(LLShaderMgr::DEFERRED_LIGHT, &mRT->dofSharp, ALSamplers::PointMirror); - gDeferredDoFCombineProgram.uniform2f(LLShaderMgr::DEFERRED_SCREEN_RES, (GLfloat)dst->getWidth(), (GLfloat)dst->getHeight()); + gDeferredDoFCombineProgram.uniform2f(LLShaderMgr::DEFERRED_SCREEN_RES, (GLfloat)mRT->screen.getWidth(), (GLfloat)mRT->screen.getHeight()); gDeferredDoFCombineProgram.uniform1f(LLShaderMgr::DOF_MAX_COF, CameraMaxCoF); gDeferredDoFCombineProgram.uniform1f(LLShaderMgr::DOF_RES_SCALE, CameraDoFResScale); - gDeferredDoFCombineProgram.uniform1f(LLShaderMgr::DOF_WIDTH, (dof_width - 1) / (F32)src->getWidth()); - gDeferredDoFCombineProgram.uniform1f(LLShaderMgr::DOF_HEIGHT, (dof_height - 1) / (F32)src->getHeight()); + // Normalised against the target the blur actually rendered + // into. Identical to the screen dimensions today because + // dofBlur is allocated full-res and merely used at a reduced + // viewport -- which is exactly why it must be written against + // dofBlur rather than left to rot if that ever changes. + gDeferredDoFCombineProgram.uniform1f(LLShaderMgr::DOF_WIDTH, (dof_width - 1) / (F32)mRT->dofBlur.getWidth()); + gDeferredDoFCombineProgram.uniform1f(LLShaderMgr::DOF_HEIGHT, (dof_height - 1) / (F32)mRT->dofBlur.getHeight()); mScreenTriangleVB->setBuffer(); mScreenTriangleVB->drawArrays(LLRender::TRIANGLES, 0, 3); gDeferredDoFCombineProgram.unbind(); - dst->flush(); + mRT->screen.flush(); + gGL.setColorMask(true, true); } } - else - { - copyRenderTarget(src, dst); - } + // No else: the pass is in place on mRT->screen, so when DoF is off + // there is nothing to copy and nothing to swap. } } @@ -9138,26 +9741,68 @@ void LLPipeline::renderFinalize() generateLuminance(&mRT->screen, &mLuminanceMap); generateExposure(&mLuminanceMap, &mExposureMap); - - // HDR bloom runs pre-tonemap against the linear scene buffer. The pyramid - // is generated here; the additive composite is folded into colorCorrect's - // tonemap variants (BLOOM_COMPOSITE permutation) so we avoid a separate - // fullscreen pass over the scene buffer. The legacy alpha-tagged prim-glow - // signal is carried into the extract pass, so prim glow survives the - // migration. compositeBloomHDR is preserved for standalone use cases. - generateBloomHDR(&mRT->screen); } // Read any pending scene probe here, while the buffer still holds linear - // radiance. One line later it has been white balanced, graded and - // tonemapped, and a sample taken then would describe the grade rather than - // the scene -- which is no use to a tool whose whole job is to decide what - // the grade should be. + // radiance and is still sharp. One line later it has been white balanced, + // graded and tonemapped, and a sample taken then would describe the grade + // rather than the scene -- which is no use to a tool whose whole job is to + // decide what the grade should be. It has to precede DoF for the same + // reason: a sample from a defocused pixel describes the blur, not the scene. serviceScenePixelProbe(&mRT->screen); + // Depth of field, in place on mRT->screen. + // + // Ahead of both the tonemapper and bloom. Pre-tonemap because gathering + // over display-space values is gathering over already-compressed + // highlights, which is why stock bokeh reads flat and why postDeferredF + // carried a weighting hack to fake the pop back. Pre-bloom because that is + // the optical order -- defocus happens at the aperture, veiling glare in + // the glass after it -- so a defocused highlight blooms as a soft disc + // instead of a sharp core floating on a blurred background. + // + // The residual, worth knowing before chasing it: the legacy alpha-tagged + // glow term reads the sharp glow tag over blurred RGB, so a defocused + // glowing prim's alpha-glow contribution hugs its sharp silhouette. The + // RGB-threshold term, which dominates in HDR, follows the blur correctly. + // If that reads badly in world, swapping this block with generateBloomHDR + // below restores bloom-first, at the cost of sharp bloom cores on + // defocused lights. + // + // SSR, luminance and exposure stay above deliberately: reflections keep + // their detail, and a blur conserves mean energy so metering is unaffected. + static LLCachedControl RenderDepthOfFieldInEditMode(gSavedSettings, "RenderDepthOfFieldInEditMode", false); + if (RenderDepthOfField && (RenderDepthOfFieldInEditMode || !LLToolMgr::getInstance()->inBuildMode()) && !gCubeSnapshot) + { + renderDoF(); + + // renderDoF calls setup3DViewport and runs a reduced viewport + // internally, so restore the world view before anything else draws. + gGLViewport[0] = gViewerWindow->getWorldViewRectRaw().mLeft; + gGLViewport[1] = gViewerWindow->getWorldViewRectRaw().mBottom; + gGLViewport[2] = gViewerWindow->getWorldViewRectRaw().getWidth(); + gGLViewport[3] = gViewerWindow->getWorldViewRectRaw().getHeight(); + glViewport(gGLViewport[0], gGLViewport[1], gGLViewport[2], gGLViewport[3]); + } + + if (hdr) + { + // HDR bloom runs pre-tonemap against the linear scene buffer -- now the + // defocused one. The pyramid is generated here; the additive composite + // is folded into colorCorrect's tonemap variants (BLOOM_COMPOSITE + // permutation) so we avoid a separate fullscreen pass over the scene + // buffer. The legacy alpha-tagged prim-glow signal is carried into the + // extract pass, so prim glow survives the migration. + generateBloomHDR(&mRT->screen); + } + // Handles tonemap, colorgrading, and gamma correction in one pass. In the HDR // path, this also applies eye adaptation and bloom. In the non-HDR path, this // is just a linear copy with color correction. + // Ahead of colorCorrect, which samples the plate, and outside the bloom + // block above because the dirt is lit by the lens flare as well as by bloom. + generateLensDirt(); + colorCorrect(&mRT->screen, &mRT->postPingMap, hdr, true); LLVertexBuffer::unbind(); @@ -9203,19 +9848,6 @@ void LLPipeline::renderFinalize() gGLViewport[3] = gViewerWindow->getWorldViewRectRaw().getHeight(); glViewport(gGLViewport[0], gGLViewport[1], gGLViewport[2], gGLViewport[3]); - static LLCachedControl RenderDepthOfFieldInEditMode(gSavedSettings, "RenderDepthOfFieldInEditMode", false); - if (RenderDepthOfField && (RenderDepthOfFieldInEditMode || !LLToolMgr::getInstance()->inBuildMode()) && !gCubeSnapshot) - { - renderDoF(sourceBuffer, targetBuffer); - std::swap(sourceBuffer, targetBuffer); - } - - gGLViewport[0] = gViewerWindow->getWorldViewRectRaw().mLeft; - gGLViewport[1] = gViewerWindow->getWorldViewRectRaw().mBottom; - gGLViewport[2] = gViewerWindow->getWorldViewRectRaw().getWidth(); - gGLViewport[3] = gViewerWindow->getWorldViewRectRaw().getHeight(); - glViewport(gGLViewport[0], gGLViewport[1], gGLViewport[2], gGLViewport[3]); - // [RLVa:KB] - @setsphere if (RlvActions::hasBehaviour(RLV_BHVR_SETSPHERE)) { @@ -9295,6 +9927,199 @@ void LLPipeline::renderFinalize() // wanted. const bool clean_plate = gSnapshotNoPost; + // Lens distortion + // + // Precompute shader-friendly forms once on the CPU: fold the master + // amount into every coefficient, pre-reciprocate the squeeze, and + // solve the auto-fit scale. The shader is then one gate followed by + // pure polynomial evaluation -- no per-pixel divides, no solve. + // + // Sign convention, worth stating because it is easy to get backwards: + // this is a gather, so the shader asks "where does this output pixel + // come from". Under that map a coefficient below 1 pulls the sample + // toward the centre, which stretches the middle of the source out to + // the frame edge -- barrel. So negative k1 reads as barrel and + // positive as pincushion, matching a lens profile, and it is + // *pincushion* that pushes samples off the source and would show + // black corners without a fit. + static LLCachedControl distort_amount(gSavedSettings, "RenderLensDistortionAmount", 0.0f, "[0, 1] default 0."); + static LLCachedControl distort_k1(gSavedSettings, "RenderLensDistortionK1", -0.2f); + static LLCachedControl distort_k2(gSavedSettings, "RenderLensDistortionK2", 0.0f); + static LLCachedControl distort_squeeze(gSavedSettings, "RenderLensDistortionSqueeze", 1.0f); + static LLCachedControl distort_fit(gSavedSettings, "RenderLensDistortionFit", 1); + static LLCachedControl distort_center(gSavedSettings, "RenderLensDistortionCenter", LLVector3(0.f, 0.f, 0.f)); + static LLCachedControl distort_tangential(gSavedSettings, "RenderLensDistortionTangential", LLVector3(0.f, 0.f, 0.f)); + + const F32 distort = clean_plate ? 0.f : llclamp(distort_amount(), 0.f, 1.f); + gBlitWithEffectsProgram.uniform1f(LLShaderMgr::LENS_DISTORT_AMOUNT, distort); + + // Zeroing the master both hits the shader's early-out and skips the + // whole solve below, exactly as the lens flare does with its strength. + if (distort > 0.f) + { + // Every shape parameter fades with the master amount, each toward + // its own neutral: the polynomial coefficients and decentering + // toward 0, the squeeze toward 1. Scaling only the coefficients + // was a shipped bug -- with a non-neutral squeeze dialled in, + // dragging Amount off zero made the radial bend fade in smoothly + // while the full anamorphic stretch snapped on in a single frame. + const F32 k1 = llclamp(distort_k1(), -0.5f, 0.5f) * distort; + const F32 k2 = llclamp(distort_k2(), -0.25f, 0.25f) * distort; + const F32 p1 = llclamp(distort_tangential().mV[0], -0.05f, 0.05f) * distort; + const F32 p2 = llclamp(distort_tangential().mV[1], -0.05f, 0.05f) * distort; + const F32 cx = llclamp(distort_center().mV[0], -0.5f, 0.5f) * distort; + const F32 cy = llclamp(distort_center().mV[1], -0.5f, 0.5f) * distort; + const F32 squeeze = 1.f + (llclamp(distort_squeeze(), 0.5f, 2.5f) - 1.f) * distort; + + // Same aspect basis the shader uses, and the same one the CA path + // derives from uResolution -- one component stays 1.0 and the + // other carries the ratio, so radial distance is measured in + // physical units and the corners really are further out than the + // edge midpoints. + const F32 res_w = (F32)gViewerWindow->getWorldViewRectRaw().getWidth(); + const F32 res_h = (F32)gViewerWindow->getWorldViewRectRaw().getHeight(); + const F32 aspect = res_w / llmax(res_h, 1.f); + const F32 axis_x = llmax(aspect, 1.f); + const F32 axis_y = llmax(1.f / llmax(aspect, 1e-4f), 1.f); + const F32 sq_x = 1.f / squeeze; + const F32 sq_y = 1.f; + + // The shader's warp with the fit scale left at 1. Scale is a pure + // multiplier on the result, so solving for it afterwards is exact + // rather than iterative. + auto base_offset = [&](F32 u, F32 v, F32& out_x, F32& out_y) + { + const F32 qx = (u - 0.5f - cx) * axis_x; + const F32 qy = (v - 0.5f - cy) * axis_y; + const F32 r2 = qx * qx + qy * qy; + const F32 radial = 1.f + r2 * (k1 + r2 * k2); + const F32 tx = 2.f * p1 * qx * qy + p2 * (r2 + 2.f * qx * qx); + const F32 ty = p1 * (r2 + 2.f * qy * qy) + 2.f * p2 * qx * qy; + out_x = (qx * radial + tx) / axis_x * sq_x; + out_y = (qy * radial + ty) / axis_y * sq_y; + }; + + // Scale at which the ray from the optical axis along `base` leaves + // the source frame -- a two-slab exit test. Below that scale the + // sample is inside the image; above it, off the edge and black. + const F32 origin_x = 0.5f + cx; + const F32 origin_y = 0.5f + cy; + auto exit_scale = [&](F32 bx, F32 by) -> F32 + { + F32 best = 1e30f; + if (bx > 1e-6f || bx < -1e-6f) + { + best = llmin(best, ((bx > 0.f ? 1.f : 0.f) - origin_x) / bx); + } + if (by > 1e-6f || by < -1e-6f) + { + best = llmin(best, ((by > 0.f ? 1.f : 0.f) - origin_y) / by); + } + return best; + }; + + F32 fit_scale = 1.f; + const S32 fit_mode = llclamp(distort_fit(), 0, 2); + if (fit_mode != 0) + { + // Walk the frame boundary densely instead of probing only the + // corners and edge midpoints. + // + // With a non-zero secondary coefficient the radial polynomial + // 1 + k1*r^2 + k2*r^4 stops being monotonic in r -- that + // non-monotonicity *is* the moustache bend -- so the largest + // outward displacement along an edge can fall between two + // sparse probes. Eight probes let that region escape the solve, + // which showed up as curved black arcs along the edges at + // extreme settings even in Fit mode. Dense sampling is also + // what keeps this honest once the tangential terms are + // non-zero, since those break the clean radial structure a + // corners-dominate argument leans on. + // + // Sixty-four evaluations of a short polynomial, once a frame, + // and only while distortion is enabled. + const S32 probes_per_edge = 16; + + // Fit (1): the smallest exit scale over every probe with a + // satisfiable constraint, so nothing that scaling can save + // ever goes black. Not quite "nothing goes black anywhere": + // with the optical axis pinned on the frame edge, fold-over + // can fling a pixel straight off that edge, and no positive + // scale brings it back -- Fit degrades to best-effort there + // rather than collapsing the whole frame chasing an + // impossible constraint. + // Fill (2): the largest, so every probe is reachable -- the + // whole source stays visible, at the cost of black corners. + F32 solved = (fit_mode == 1) ? 1e30f : 0.f; + + auto consider_probe = [&](F32 u, F32 v) + { + F32 bx, by; + base_offset(u, v, bx, by); + const F32 s = exit_scale(bx, by); + // >= 1e30 is no constraint at all (base ~ 0 near the + // optical axis); ~0 is the unsatisfiable case above. + if (s < 1e-4f || s >= 1e30f) + { + return; + } + solved = (fit_mode == 1) ? llmin(solved, s) : llmax(solved, s); + }; + + for (S32 edge = 0; edge < 4; ++edge) + { + for (S32 i = 0; i < probes_per_edge; ++i) + { + // t == 0 lands exactly on a corner, so all four corners + // are still probed; the rest subdivide each edge. + const F32 t = (F32)i / (F32)probes_per_edge; + switch (edge) + { + case 0: consider_probe(t, 0.f); break; // top + case 1: consider_probe(1.f, t); break; // right + case 2: consider_probe(1.f - t, 1.f); break; // bottom + default: consider_probe(0.f, 1.f - t); break; // left + } + } + } + + // Fit also probes the interior. Boundary-only probing assumes + // the binding constraint lies on the frame edge, which holds + // while the polynomial is monotonic over the frame -- but + // strong barrel folds it over (the radial factor goes negative + // past its turning point), and then an interior pixel can be + // flung further than any boundary pixel. Verified numerically: + // at clamp-edge settings a boundary-only solve passed all 64 + // probes while a mid-frame island escaped. A 15x15 grid + // catches every satisfiable interior bind for a few hundred + // cheap evaluations. Fit only: Fill takes the max, which the + // near-axis interior would poison with huge exit scales. + if (fit_mode == 1) + { + const S32 grid = 15; + for (S32 gy = 1; gy <= grid; ++gy) + { + for (S32 gx = 1; gx <= grid; ++gx) + { + consider_probe((F32)gx / (F32)(grid + 1), + (F32)gy / (F32)(grid + 1)); + } + } + } + + if (solved > 0.f && solved < 1e30f) + { + fit_scale = llclamp(solved, 0.1f, 10.f); + } + } + + gBlitWithEffectsProgram.uniform2f(LLShaderMgr::LENS_DISTORT_K, k1, k2); + gBlitWithEffectsProgram.uniform1f(LLShaderMgr::LENS_DISTORT_SCALE, fit_scale); + gBlitWithEffectsProgram.uniform2f(LLShaderMgr::LENS_DISTORT_SQUEEZE, sq_x, sq_y); + gBlitWithEffectsProgram.uniform2f(LLShaderMgr::LENS_DISTORT_CENTER, cx, cy); + gBlitWithEffectsProgram.uniform2f(LLShaderMgr::LENS_DISTORT_TANGENTIAL, p1, p2); + } + // Vignette static LLCachedControl vignette_amount(gSavedSettings, "RenderVignetteAmount", 0.0f, "[0, 1] default 0."); static LLCachedControl vignette_radius(gSavedSettings, "RenderVignetteRadius", 1.0f); diff --git a/indra/newview/pipeline.h b/indra/newview/pipeline.h index b496aae393..bb926d0d7e 100644 --- a/indra/newview/pipeline.h +++ b/indra/newview/pipeline.h @@ -96,6 +96,7 @@ class LLPipeline void createGLBuffers(); void createLUTBuffers(); void setupGradingLUT(); + void generateLensDirt(); //allocate the largest screen buffer possible up to resX, resY //returns true if full size buffer allocated, false if some other size is allocated @@ -148,7 +149,10 @@ class LLPipeline void applyFXAA(LLRenderTarget* src, LLRenderTarget* dst); void generateSMAABuffers(LLRenderTarget* src); void applySMAA(LLRenderTarget* src, LLRenderTarget* dst); - void renderDoF(LLRenderTarget* src, LLRenderTarget* dst); + // Operates in place on mRT->screen: the combine writes colour back under a + // mask that leaves the prim-glow alpha untouched, so callers neither pass + // buffers nor swap afterwards. + void renderDoF(); void copyRenderTarget(LLRenderTarget* src, LLRenderTarget* dst); void combineGlow(LLRenderTarget* src, LLRenderTarget* dst); void visualizeBuffers(LLRenderTarget* src, LLRenderTarget* dst, U32 bufferIndex); @@ -823,6 +827,23 @@ class LLPipeline LLRenderTarget postPingMap; LLRenderTarget postPongMap; + // Depth of field scratch, owned by the DoF pass alone. + // + // DoF runs pre-tonemap on linear HDR, so it cannot borrow postPingMap + // (GL_RGB10_A2 under HDR). It deliberately does not borrow + // deferredLight either: that is the SSAO / sun-shadow factor buffer + // every deferred lighting shader samples, and widening it to RGBA16F + // to serve one late pass would double a frame-wide bandwidth cost on + // exactly the low-end hardware this DoF path exists for. + // + // dofSharp is RGBA16F because it carries the sharp linear copy plus + // the signed CoF in alpha. dofBlur drops alpha entirely -- the combine + // reads CoF from dofSharp and its own alpha output is discarded by the + // colour mask -- so it uses the pyramid's R11F_G11F_B10F and costs half. + // Both are main-pack only and released whenever DoF is off. + LLRenderTarget dofSharp; + LLRenderTarget dofBlur; + //sun shadow map LLRenderTarget shadow[4]; @@ -830,6 +851,16 @@ class LLPipeline // mBloomMip[0] is full-res extract; subsequent levels are halved. LLRenderTarget bloomMip[BLOOM_MAX_MIPS]; U32 bloomMipCount = 0; + + // Cross-screen filter ping-pong. Allocated on the first frame the + // effect is actually on and released again when it is switched off, so + // the strength control can stay a live slider -- wiring a slider to a + // reallocation handler would fire on every mouse-move. + LLRenderTarget crossFilter[3]; + // The height the targets were last (re)built for -- kept even when + // the build FAILED, so an impossible size is not retried every frame; + // pair it with isComplete() to tell the two states apart. + U32 crossFilterHeight = 0; }; // main full resoltuion render target @@ -985,6 +1016,40 @@ class LLPipeline U32 mSMAASearchMap = 0; U32 mSMAASampleMap = 0; + // Lens dirt plate, generated rather than loaded -- see generateLensDirt. + // Nothing is allocated until the effect is switched on, and the memory goes + // back when it is switched off, so an incomplete target is also the signal + // to force the strength uniform to 0 and leave the sampler unread. + LLRenderTarget mLensDirtMap; + + // What the current plate was generated from. Comparing the whole set each + // frame is what triggers a rebuild, which covers parameter edits and window + // resizes through one test and needs no commit-signal plumbing. It also + // records a *failed* attempt, so a plate that could not be allocated is + // retried when something changes rather than on every frame. + struct LensDirtParams + { + U32 width = 0; + U32 height = 0; + F32 seed = -1.f; + F32 grime = -1.f; + F32 mote_scale = -1.f; + F32 smudge = -1.f; + S32 scratches = -1; + F32 toe = -1.f; + F32 gain = -1.f; + + bool operator==(const LensDirtParams&) const = default; + }; + LensDirtParams mLensDirtParams; + + // Raised by the Lightbox while one of the generation sliders is being + // dragged. The plate is full-resolution, so rebuilding on every frame of a + // drag is a stutter rather than a preview -- and worse the slower the + // machine, which is backwards. Holding off means the rebuild lands once, on + // release, which is also where the user expects to see the result. + bool mLensDirtSliderHeld = false; + LLColor4 mSunDiffuse; LLColor4 mMoonDiffuse; LLVector4 mSunDir; diff --git a/indra/newview/skins/default/xui/en/panel_lightbox_lens.xml b/indra/newview/skins/default/xui/en/panel_lightbox_lens.xml index f4e8bf8c08..ba53a0ea83 100644 --- a/indra/newview/skins/default/xui/en/panel_lightbox_lens.xml +++ b/indra/newview/skins/default/xui/en/panel_lightbox_lens.xml @@ -360,36 +360,105 @@ rebuild. Revisit if RenderBloomHDR gets wired.) + name="sec_bokeh"> + + + + + + + + + + + + + increment="0.05" + min_val="0.25" + max_val="4" + name="bokeh_anamorphic" + tool_tip="Stretches defocused highlights into ovals the way an anamorphic lens does. 1.0 is round; above 1 is taller than wide. Area preserving, so it changes the shape of the blur without changing its strength" + enabled_control="RenderDepthOfField" + control_name="RenderBokehAnamorphicSqueeze" /> + max_val="4" + name="bokeh_highlight_gain" + tool_tip="Extra weight on bright samples, making defocused highlights read as distinct discs. 0 is a plain average" + enabled_control="RenderDepthOfField" + control_name="RenderBokehHighlightGain" /> + min_val="0" + max_val="1.5" + name="bokeh_cat_eye" + tool_tip="Optical vignetting; clips bokeh into lens-shaped slivers by distance from the image circle centre, so a wide display clips at the sides well before the top and bottom" + enabled_control="RenderDepthOfField" + control_name="RenderBokehCatEyeAmount" /> + name="sec_bokeh_adv"> + max_val="8" + name="bokeh_highlight_threshold" + tool_tip="Brightness where the highlight boost starts. 0 boosts everything; raising it restricts the effect to genuine highlights" + enabled_control="RenderDepthOfField" + control_name="RenderBokehHighlightThreshold" /> + max_val="1024" + name="bokeh_highlight_clamp" + tool_tip="Caps how much a single bright sample may contribute to a neighbouring disc. Lower stops glints shimmering as the camera moves; 0 disables the cap" + enabled_control="RenderDepthOfField" + control_name="RenderBokehHighlightClamp" /> + min_val="0" + max_val="360" + name="bokeh_blade_rotation" + tool_tip="Rotates the aperture polygon. No effect on a circular aperture" + enabled_control="RenderDepthOfField" + control_name="RenderBokehApertureRotation" /> - - - - - - - - + height="16" + label="Blade Curvature" + label_width="140" + can_edit_text="true" + decimal_digits="2" + increment="0.01" + min_val="0" + max_val="1" + name="bokeh_blade_curvature" + tool_tip="Rounds the blades back toward a circle, as a rounded diaphragm does when held open. No effect on a circular aperture" + enabled_control="RenderDepthOfField" + control_name="RenderBokehApertureCurvature" /> - + name="bokeh_fringe_amount" + tool_tip="Colour cast on the rim of defocused highlights, flipping hue either side of focus. Separate from the frame-wide Chromatic Aberration section" + enabled_control="RenderDepthOfField" + control_name="RenderBokehFringeAmount" /> + name="bokeh_fringe_near_label" + value="Near tint" /> + name="bokeh_fringe_near_tint" + tool_tip="Cast on highlights in front of the focal plane" + enabled_control="RenderDepthOfField" + control_name="RenderBokehFringeNearTint" /> + + + - + - Applies when HDR rendering is off. - - + label="Spherical" + label_width="140" + can_edit_text="true" + decimal_digits="2" + increment="0.01" + min_val="-1" + max_val="1" + name="aberration_spherical" + tool_tip="Hollows defocused highlights into bright-rimmed bubbles, or fills them in for a creamy blur. Flips either side of focus, as real glass does" + enabled_control="RenderDepthOfField" + control_name="RenderBokehSphericalAberration" /> + + name="aberration_field" + tool_tip="Stretches defocused highlights toward the frame edges; positive swirls them around the centre, negative draws them into comet shapes" + enabled_control="RenderDepthOfField" + control_name="RenderBokehFieldStretch" /> + + + max_val="1" + name="aberration_coma" + tool_tip="Draws each highlight into a comet with its tail pointing away from frame centre; zero on the optical axis, strongest in the corners" + enabled_control="RenderDepthOfField" + control_name="RenderBokehComaAsymmetry" /> + name="sec_bloom"> + decimal_digits="2" + increment="0.01" + min_val="0" + max_val="2" + name="bloom_strength" + tool_tip="How strongly the bloom is blended into the scene" + enabled_control="RenderHDREnabled" + control_name="RenderBloomStrength" /> + max_val="8" + name="bloom_threshold" + tool_tip="Luminance (linear light) above which pixels bloom; 1.0 is roughly brighter-than-white" + enabled_control="RenderHDREnabled" + control_name="RenderBloomThreshold" /> + decimal_digits="2" + increment="0.01" + min_val="0.5" + max_val="2.5" + name="bloom_scatter" + tool_tip="Bloom spread per octave. Above about 2.5 the filter starts to alias." + enabled_control="RenderHDREnabled" + control_name="RenderBloomScatter" /> + + + + + + decimal_digits="2" + increment="0.01" + min_val="0" + max_val="1" + name="bloom_knee" + tool_tip="Soft-knee width below the threshold; wider gives a more gradual bloom onset" + enabled_control="RenderHDREnabled" + control_name="RenderBloomKnee" /> - + max_val="8" + name="bloom_alpha_boost" + tool_tip="Multiplier for legacy prim-glow when fed into HDR bloom, so glowing objects match legacy brightness" + enabled_control="RenderHDREnabled" + control_name="RenderBloomAlphaGlowBoost" /> - - - - - - - - - - + top_pad="9" + right="-32" + height="16" + label="Mip count" + label_width="140" + can_edit_text="true" + decimal_digits="0" + increment="1" + min_val="3" + max_val="7" + name="bloom_mips" + tool_tip="Bloom pyramid levels; more mips = wider, softer bloom. Changing causes a brief hitch." + enabled_control="RenderHDREnabled" + control_name="RenderBloomMipCount" /> - + - - - - - - - - + name="bloom_res_scale_combo" + tool_tip="Resolution the bloom pyramid starts at. Lower resolutions are faster and reach wider per mip. Changing causes a brief hitch." + enabled_control="RenderHDREnabled" + control_name="RenderBloomResolutionScale"> + + + + + + - - - - - + label="Halation" + tool_tip="Enable the warm halo signal around bright highlights (uses a wider pyramid format). Toggling causes a brief hitch." + name="bloom_halation" + enabled_control="RenderHDREnabled" + control_name="RenderBloomHalation" /> + name="bloom_halation_strength" + tool_tip="Photographic halation amount; 0.25 is a gentle cinematic feel" + enabled_control="RenderBloomHalation" + control_name="RenderBloomHalationStrength" /> + name="bloom_halation_tint_label" + value="Halation tint" /> + name="bloom_halation_tint" + tool_tip="Color of the halation halo; default is warm amber" + enabled_control="RenderBloomHalation" + control_name="RenderBloomHalationTint" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Applies when HDR rendering is off. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + decimal_digits="3" + text_width="56" + increment="0.001" + min_val="0.005" + max_val="0.1" + name="flare_occlusion_radius" + tool_tip="Width of the sun-occlusion depth test; larger lets thin geometry dim the flare gracefully" + control_name="RenderLensFlareOcclusionRadius" /> + decimal_digits="0" + increment="1" + min_val="1" + max_val="32" + name="flare_occlusion_taps" + tool_tip="Depth samples for occlusion testing; more gives smoother transitions at slight cost" + control_name="RenderLensFlareOcclusionTaps" /> - + name="sec_lensdirt"> + name="lensdirt_strength" + tool_tip="How strongly grime on the front element catches light. Only shows where there is already bloom or flare to catch, so a flat scene stays clean" + control_name="RenderLensDirtStrength" /> + decimal_digits="2" + increment="0.01" + min_val="0" + max_val="2" + name="lensdirt_grime" + tool_tip="How much muck is on the glass. Scales every layer of dust and grit at once" + control_name="RenderLensDirtGrime"> + + + + min_val="0.5" + max_val="2" + name="lensdirt_mote_scale" + tool_tip="Size of the dust specks. Above 1 they grow and thin out; below 1 they shrink into finer grime" + control_name="RenderLensDirtMoteScale"> + + + + max_val="2" + name="lensdirt_smudge" + tool_tip="Strength of the broad wipe marks a cloth leaves behind, as opposed to the discrete specks" + control_name="RenderLensDirtSmudge"> + + + + max_val="32" + name="lensdirt_scratches" + tool_tip="How many scratches cross the glass. 0 is undamaged. They are straight and bright where dust is soft, so a few go a long way" + control_name="RenderLensDirtScratches"> + + + - - + top_pad="10" + label="Reset All" + halign="left" + scale_image="true" + image_overlay="Refresh_Off" + image_overlay_alignment="right" + name="sec_lensdirt_reset" + tool_tip="Reset this section to defaults"> + + + + + + + decimal_digits="0" + increment="1" + min_val="0" + max_val="999" + name="lensdirt_seed" + tool_tip="Reshuffles the grime. Also moves the overall density a little, since dirt pools rather than spreading evenly and where it pools changes with the seed" + control_name="RenderLensDirtSeed"> + + + + decimal_digits="2" + increment="0.01" + min_val="0.6" + max_val="4" + name="lensdirt_toe" + tool_tip="How much of the faint grime survives. Higher values crush the midtones and leave only the brightest specks, which reads clean whatever Strength is set to" + control_name="RenderLensDirtToe"> + + + - - + increment="0.01" + min_val="0.5" + max_val="2.5" + name="lensdirt_gain" + tool_tip="Overall brightness of the generated plate, applied after the toe. Mostly a trim for Toe" + control_name="RenderLensDirtGain"> + + + + max_val="2" + name="lensdirt_bloom_response" + tool_tip="How much bloom lights up the dirt. 0 lets bloom pass through a clean lens" + control_name="RenderLensDirtBloomResponse" /> + min_val="0" + max_val="2" + name="lensdirt_flare_response" + tool_tip="How much the lens flare lights up the dirt. Flare and grime share the same front element, so this is the response that reads most like a real lens" + control_name="RenderLensDirtFlareResponse" /> - - + top_pad="10" + label="Reset All" + halign="left" + scale_image="true" + image_overlay="Refresh_Off" + image_overlay_alignment="right" + name="sec_lensdirt_adv_reset" + tool_tip="Reset this section to defaults"> + + + + + + + max_val="1" + name="ca_strength" + tool_tip="Color fringe intensity. 0 disables; 0.15 is a subtle cinema lens feel." + control_name="RenderChromaticAberrationStrength" /> + increment="0.05" + min_val="0.5" + max_val="4" + name="ca_falloff" + tool_tip="How quickly fringing grows from center to edge; higher keeps the center clean" + control_name="RenderChromaticAberrationFalloff" /> + decimal_digits="0" + increment="1" + min_val="0" + max_val="360" + name="ca_angle" + tool_tip="Rotates the red/blue split direction (degrees); only matters when anisotropy is nonzero" + control_name="RenderChromaticAberrationAngle" /> - - + decimal_digits="2" + increment="0.01" + min_val="-1" + max_val="1" + name="ca_anisotropy" + tool_tip="Stretches the fringe along the angle axis for an anamorphic-style directional smear" + control_name="RenderChromaticAberrationAnisotropy" /> + decimal_digits="2" + increment="0.01" + min_val="-1" + max_val="1" + name="ca_offset_rx" + tool_tip="Red channel horizontal shift direction" + control_name="RenderChromaticAberrationOffsetRX" /> + name="ca_offset_ry" + tool_tip="Red channel vertical shift direction" + control_name="RenderChromaticAberrationOffsetRY" /> - - + decimal_digits="2" + increment="0.01" + min_val="-1" + max_val="1" + name="ca_offset_bx" + tool_tip="Blue channel horizontal shift direction; typically opposite the red offset" + control_name="RenderChromaticAberrationOffsetBX" /> + label="Blue offset Y" + label_width="140" + can_edit_text="true" + decimal_digits="2" + increment="0.01" + min_val="-1" + max_val="1" + name="ca_offset_by" + tool_tip="Blue channel vertical shift direction" + control_name="RenderChromaticAberrationOffsetBY" /> + name="sec_distortion"> + name="distortion_amount" + tool_tip="Overall lens distortion strength; 0 disables. Only the world view bends - nametags, outlines and UI stay straight." + control_name="RenderLensDistortionAmount" /> + increment="0.01" + min_val="-0.5" + max_val="0.5" + text_width="56" + name="distortion_k1" + tool_tip="Negative bows straight lines outward (barrel, the wide-angle look); positive bows them inward (pincushion)" + control_name="RenderLensDistortionK1" /> + decimal_digits="2" + increment="0.01" + min_val="0.5" + max_val="2.5" + name="distortion_squeeze" + tool_tip="Horizontal desqueeze; 1.0 is a spherical lens, 2.0 matches anamorphic cinema" + control_name="RenderLensDistortionSqueeze" /> - + + + + + min_val="-0.25" + max_val="0.25" + text_width="56" + name="distortion_k2" + tool_tip="Shapes the bend toward the corners. Small non-zero values give the moustache distortion of real wide-angle lenses, where the bend reverses near the frame edge." + control_name="RenderLensDistortionK2" /> - + + height="18" + name="distortion_fit_combo" + tool_tip="How the bent image is rescaled: none leaves pincushion showing black corners, Fit scales until nothing goes black, Fill keeps the whole frame visible and may letterbox the corners" + control_name="RenderLensDistortionFit"> + + + + - + + min_val="-0.5" + max_val="0.5" + name="vec3_RenderLensDistortionCenter_0"> + + + + + - + top_pad="10" + width="110" + height="15" + name="distortion_tangential_label" + tool_tip="Decentering terms p1 and p2, modelling a lens element mounted slightly off-axis. Realistic values are tiny." + value="Tangential" /> + + + + + + diff --git a/indra/newview/viewer_manifest.py b/indra/newview/viewer_manifest.py index af283f1c17..176bfc5a05 100755 --- a/indra/newview/viewer_manifest.py +++ b/indra/newview/viewer_manifest.py @@ -104,6 +104,8 @@ def construct(self): # ... and the entire color grading LUT directory self.path("colorlut") + # ... and the bundled lens dirt plates + # ... and the bundled starter Looks self.path("looks") diff --git a/scripts/content_tools/check_lens_dirt.py b/scripts/content_tools/check_lens_dirt.py new file mode 100644 index 0000000000..a40b7d4aaf --- /dev/null +++ b/scripts/content_tools/check_lens_dirt.py @@ -0,0 +1,391 @@ +#!/usr/bin/env python3 +"""\ +@file check_lens_dirt.py +@brief Offline harness for the procedural lens dirt generator. + + The plate is generated on the GPU by lensDirtGenF.glsl, which cannot be + inspected from here. This mirrors that shader in Python and answers the + one question the GLSL cannot answer on its own: does the plate still + read as dirt? + + That question is not rhetorical. The four plates this effect used to + ship with were, on their first bake, almost invisible at maxed sliders + -- every one of them had bright specks and nothing in between, and the + histogram is what caught it. Coverage and peak are therefore scored + against per-preset targets carried over from the plates that replaced + them. The median is reported but not scored: a subtle plate is supposed + to have a near-black median, and scoring it would push every preset to + the same level. + + A Python mirror of a shader is only worth having while it stays a + mirror, so --verify-port compares every numeric constant in this file's + build() against the shader's main() and fails if they have drifted. + Run it before trusting any number this script prints. + + Usage: + python scripts/content_tools/check_lens_dirt.py + python scripts/content_tools/check_lens_dirt.py --seeds 8 + python scripts/content_tools/check_lens_dirt.py --sheet dirt.png + + Unlike the bake script it replaces, this uses numpy. That script + avoided it because it produced shipped assets and the viewer's build + environment has no numpy; this one produces nothing the viewer loads, + so the constraint no longer applies -- and a pure-Python port of a + few-hundred-ALU shader would take minutes per plate. + +$LicenseInfo:firstyear=2026&license=viewerlgpl$ +Alchemy Viewer Source Code +Copyright (C) 2026, Alchemy Viewer Project + +This library is free software; you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation; +version 2.1 of the License only. + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +$/LicenseInfo$ +""" + +import argparse +import os +import re +import sys + +try: + import numpy as np +except ImportError: + sys.exit("numpy is required: pip install numpy") + + +SHADER = os.path.join("indra", "newview", "app_settings", "shaders", + "class1", "effects", "lensDirtGenF.glsl") + +# Layer densities and tone curve per preset, plus the histogram it is aiming +# for. These are the four looks the bundled plates used to cover, kept as +# starting points rather than as shipped files -- the settings' Comments carry +# the same numbers so a user can dial one in. +# +# `want` is (p50, p99, coverage above 0.25) with a generous tolerance. It +# exists to catch a preset drifting back into invisibility, not to pin exact +# numbers. +PRESETS = { + "Subtle": dict(grime=0.45, mote_scale=1.0, smudge=0.5, scratches=0, + toe=1.8, gain=1.00, want=(0.01, 0.30, 0.03), + blurb="a clean lens that has simply been outdoors"), + "Dirty": dict(grime=1.00, mote_scale=1.0, smudge=1.0, scratches=0, + toe=1.6, gain=1.00, want=(0.06, 0.50, 0.12), + blurb="a working lens nobody has wiped in a while"), + "Extreme": dict(grime=1.90, mote_scale=1.0, smudge=1.5, scratches=0, + toe=1.2, gain=1.10, want=(0.16, 0.75, 0.37), + blurb="filthy -- fingerprints, sea spray, a bad day"), + "Damaged": dict(grime=0.95, mote_scale=1.0, smudge=0.9, scratches=16, + toe=1.2, gain=0.90, want=(0.07, 0.50, 0.19), + blurb="grime plus physical damage: scratches and chips"), +} + + +# ---------------------------------------------------------------- hashes ---- +def fract(x): + return x - np.floor(x) + + +def hash21(px, py, seed): + return fract(np.sin(px * 127.1 + py * 311.7 + seed * 74.7) * 43758.5453) + + +def hash22(px, py, seed): + return (fract(np.sin(px * 127.1 + py * 311.7 + seed * 74.7) * 43758.5453), + fract(np.sin(px * 269.5 + py * 183.3 + seed * 51.3) * 43758.5453)) + + +def screen(a, b): + return 1.0 - (1.0 - a) * (1.0 - b) + + +# ------------------------------------------------------------ mote layer ---- +def motes(u, v, cells, r_min, r_max, softness, seed, aspect, density, bias): + """One jittered disc per grid cell over the 3x3 neighbourhood. + + Cells are dropped when their hash exceeds `density`, because one feature + per cell is perfectly even and dirt is not. Radii are raised to `bias` so + most motes come out small with a few large. The profile is a flat interior + with a quick rim rather than a Gaussian -- a Gaussian has no edge anywhere + and reads as fog the moment neighbours overlap.""" + acc = np.zeros_like(u) + cx, cy = np.floor(u * cells), np.floor(v * cells) + for ox in (-1, 0, 1): + for oy in (-1, 0, 1): + gx, gy = cx + ox, cy + oy + keep = hash21(gx, gy, seed + 57.0) < density + jx, jy = hash22(gx, gy, seed) + fx, fy = (gx + jx) / cells, (gy + jy) / cells + rad = r_min + (r_max - r_min) * np.power(hash21(gx, gy, seed + 19.0), bias) + d = np.sqrt(((u - fx) * aspect) ** 2 + (v - fy) ** 2) + x = np.clip((rad - d) / (rad * softness + 1e-9), 0.0, 1.0) + acc = screen(acc, x * x * (3.0 - 2.0 * x) * keep) + return acc + + +# ----------------------------------------------------------- value noise ---- +def vnoise(u, v, freq, seed): + x, y = u * freq, v * freq + ix, iy = np.floor(x), np.floor(y) + fx, fy = x - ix, y - iy + fx = fx * fx * (3.0 - 2.0 * fx) + fy = fy * fy * (3.0 - 2.0 * fy) + a = hash21(ix, iy, seed) + b = hash21(ix + 1, iy, seed) + c = hash21(ix, iy + 1, seed) + d = hash21(ix + 1, iy + 1, seed) + return (a * (1 - fx) + b * fx) * (1 - fy) + (c * (1 - fx) + d * fx) * fy + + +def fbm3(u, v, freq, seed): + total, amp, norm = np.zeros_like(u), 1.0, 0.0 + for i in range(3): + total += amp * vnoise(u, v, freq * (2 ** i), seed + i * 13.0) + norm += amp + amp *= 0.5 + return total / norm + + +# ------------------------------------------------------------- segments ----- +def seg_dist(u, v, ax, ay, bx, by, aspect): + pax, pay = (u - ax) * aspect, v - ay + bax, bay = (bx - ax) * aspect, by - ay + h = np.clip((pax * bax + pay * bay) / max(bax * bax + bay * bay, 1e-9), 0.0, 1.0) + return np.sqrt((pax - bax * h) ** 2 + (pay - bay * h) ** 2) + + +def lines(u, v, count, length, width, seed, aspect, wander): + """Fibres and scratches: a segment distance field, domain-warped by + low-frequency noise so a fibre wanders while a scratch stays straight.""" + uu, vv = u, v + if wander > 0.0: + uu = u + (fbm3(u, v, 3.0, seed + 7.0) - 0.5) * wander + vv = v + (fbm3(u, v, 3.0, seed + 31.0) - 0.5) * wander + acc = np.zeros_like(u) + for i in range(count): + fi = float(i) + ax = hash21(fi, 1.0, seed) + ay = hash21(fi, 2.0, seed) + ang = hash21(fi, 3.0, seed) * 6.2831853 + ln = length * (0.4 + 0.6 * hash21(fi, 4.0, seed)) + bx, by = ax + np.cos(ang) * ln, ay + np.sin(ang) * ln + w = width * (0.5 + 0.5 * hash21(fi, 5.0, seed)) + d = seg_dist(uu, vv, ax, ay, bx, by, aspect) + t = np.clip((w - d) / max(w, 1e-9), 0.0, 1.0) + acc = np.maximum(acc, t * t * (3.0 - 2.0 * t) * (0.55 + 0.45 * hash21(fi, 6.0, seed))) + return acc + + +# ---------------------------------------------------------------- build ----- +def build(w, h, seed, grime, mote_scale, smudge, scratches, toe, gain): + """Mirror of lensDirtGenF.glsl's main(). + + Written statement for statement against the shader, and deliberately so: + the port check below compares the two constant streams in order, which only + works while the two read in parallel. That is why `img` is spelled with an + explicit 0.0 and the vignette uses hypot on both sides -- idiomatic numpy + would drop constants the shader has, or add ones it does not, and the check + would start reporting drift that is not there.""" + aspect = w / float(h) + v, u = np.meshgrid(np.linspace(0, 1, h, endpoint=False), + np.linspace(0, 1, w, endpoint=False), indexing='ij') + mscale = max(mote_scale, 0.05) + + clump = fbm3(u, v, 5.0, seed + 101.0) + clump = 0.62 + 0.76 * np.clip((clump - 0.34) / 0.36, 0.0, 1.0) + + img = np.full_like(u, 0.0) + img = screen(img, motes(u, v, 4.5 / mscale, 0.050, 0.140, 0.45, + seed, aspect, 0.80 * grime, 1.7) * 0.46 * clump) + img = screen(img, motes(u, v, 9.0 / mscale, 0.022, 0.064, 0.42, + seed + 3.0, aspect, 0.75 * grime, 1.9) * 0.50 * clump) + img = screen(img, motes(u, v, 20.0 / mscale, 0.009, 0.028, 0.40, + seed + 5.0, aspect, 0.60 * grime, 2.1) * 0.44 * clump) + img = screen(img, motes(u, v, 90.0, 0.0011, 0.0030, 0.55, + seed + 9.0, aspect, 0.34 * grime, 1.5) * 0.75) + + sm = fbm3(u, v, 1.6, seed + 21.0) + img = screen(img, np.power(np.clip((sm - 0.50) / 0.34, 0.0, 1.0), 1.4) * 0.34 * smudge) + + img = screen(img, lines(u, v, 12, 0.20, 0.0014, seed + 41.0, aspect, 0.06) * 0.62) + + if scratches > 0: + img = screen(img, lines(u, v, scratches, 0.55, 0.0022, seed + 57.0, aspect, 0.0)) + + r = np.clip(np.hypot((u - 0.5) * aspect, v - 0.5) / (0.5 * np.hypot(aspect, 1.0)), 0.0, 1.0) + img *= 0.22 + 0.78 * np.power(r, 0.75) + + return np.clip(gain * np.power(np.clip(img, 0.0, 1.0), toe), 0.0, 1.0) + + +# ---------------------------------------------------------- port checking --- +def _floats(text): + """Numeric literals, normalised so 9 and 9.0 compare equal.""" + return [float(x) for x in re.findall(r'(?= 0.25).mean())) + + +def main(): + ap = argparse.ArgumentParser(description="Check the procedural lens dirt generator.") + ap.add_argument("--width", type=int, default=768, help="plate width (default 768)") + ap.add_argument("--height", type=int, default=768, help="plate height (default 768)") + ap.add_argument("--seeds", type=int, default=4, + help="seeds per preset; dirt pools, so density varies with the seed") + ap.add_argument("--preset", choices=sorted(PRESETS), help="check one variant") + ap.add_argument("--sheet", help="also write a contact sheet to this PNG (needs Pillow)") + ap.add_argument("--shader", default=SHADER, help="shader to check the port against") + ap.add_argument("--skip-port-check", action="store_true", + help="measure without first checking the mirror against the shader") + args = ap.parse_args() + + # Zero or negative on any of these produces an empty plate or an empty + # sample set, and the failure surfaces much later as a ZeroDivisionError out + # of the averaging or an IndexError out of describe(). argparse can say what + # is actually wrong instead. + if args.width < 1 or args.height < 1: + ap.error("--width and --height must be at least 1") + if args.seeds < 1: + ap.error("--seeds must be at least 1") + + if not args.skip_port_check and not verify_port(args.shader): + return 2 + print() + + names = [args.preset] if args.preset else sorted(PRESETS) + order = sorted(PRESETS) + tiles, worst = [], 0.0 + + print("%-9s %6s %6s %8s %s" % ("preset", "p50", "p99", ">0.25", "check")) + for name in names: + cfg = PRESETS[name] + w50, w99, wcov = cfg["want"] + # Offset per preset so the variants are different lenses rather than the + # same lens at four exposures, keyed to the preset's fixed position so + # checking one alone reproduces what checking all of them produced. + base = 7.0 + order.index(name) * 11.0 + stats = [] + for k in range(args.seeds): + a = build(args.width, args.height, base + k * 97.0, + cfg["grime"], cfg["mote_scale"], cfg["smudge"], + cfg["scratches"], cfg["toe"], cfg["gain"]) + stats.append(describe(a)) + if k == 0: + tiles.append((name, a)) + p50 = sum(s[0] for s in stats) / len(stats) + p99 = sum(s[1] for s in stats) / len(stats) + cov = sum(s[2] for s in stats) / len(stats) + off = max(abs(p99 - w99) / max(w99, 1e-3), + abs(cov - wcov) / max(wcov, 1e-3)) + worst = max(worst, off) + print("%-9s %6.3f %6.3f %7.1f%% %s (want %.2f/%.2f/%.0f%%, off %.0f%%)" + % (name, p50, p99, 100 * cov, + "ok" if off < 0.35 else "DRIFTED", w50, w99, 100 * wcov, 100 * off)) + + if args.sheet: + try: + from PIL import Image, ImageDraw + except ImportError: + print("\n--sheet needs Pillow: pip install Pillow") + else: + cols = 2 if len(tiles) > 1 else 1 + rows = (len(tiles) + cols - 1) // cols + W, H = args.width, args.height + sheet = Image.new("RGB", (cols * (W + 10) + 10, rows * (H + 10) + 10), (16, 16, 18)) + d = ImageDraw.Draw(sheet) + for i, (name, a) in enumerate(tiles): + im = Image.fromarray((a * 255).astype(np.uint8), "L").convert("RGB") + x, y = 10 + (i % cols) * (W + 10), 10 + (i // cols) * (H + 10) + sheet.paste(im, (x, y)) + d.text((x + 8, y + 7), name, fill=(255, 236, 170)) + sheet.save(args.sheet) + print("\nwrote %s" % args.sheet) + + if worst >= 0.35: + print("\nAt least one preset is off its target histogram by more than a third.") + print("Adjust its toe/gain in PRESETS -- a lower toe keeps more midtone --") + print("then carry the change into lensDirtGenF.glsl's defaults and the settings.") + return 1 + + return 0 + + +if __name__ == "__main__": + # The drift check is only worth having if something can act on it, so the + # verdict reaches the exit status. + sys.exit(main())