diff --git a/doc/ARCHITECTURE.md b/doc/ARCHITECTURE.md index fdd56a0b4b..cb55884c32 100644 --- a/doc/ARCHITECTURE.md +++ b/doc/ARCHITECTURE.md @@ -114,6 +114,8 @@ The deferred rendering pipeline is orchestrated by `LLPipeline` (`indra/newview/ - **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 +Post-processing settings are exposed in the Lightbox floater (`ALFloaterLightBox`); see `doc/LIGHTBOX.md` for how to add UI sections for new effects. + ### Shader System Shader management has two layers: diff --git a/doc/LIGHTBOX.md b/doc/LIGHTBOX.md new file mode 100644 index 0000000000..50dde645fe --- /dev/null +++ b/doc/LIGHTBOX.md @@ -0,0 +1,941 @@ +# Lightbox maintainer guide + +How to add a new effect section to the Lightbox floater. The floater is built +so that exposing a new post-processing effect through the usual rows is +(almost always) a pure XUI change: no new C++, no layout mathematics beyond the +rules below, resets and live preview for free. + +Richer controls — colour wheels, graphs, the eyedropper, the scopes, a switch on +a section header — do have C++ behind them, written once each and then reused +from XUI like any other row. Sections 3b and 4b to 4d cover those; if you only +need sliders and checkboxes you can skip them. + +## Architecture in brief + +The Lightbox is a *window onto settings the renderer already watches*. Every +row binds a widget to a `gSavedSettings` key via `control_name`; the renderer +reads those keys on the frame path (`LLCachedControl` or signal-refreshed +statics), so edits preview live with no glue code. + +| Piece | File | +|---|---| +| Floater shell + Looks bar | `indra/newview/skins/default/xui/en/floater_lightbox_settings.xml` | +| Look tab (color science) | `.../panel_lightbox_look.xml` | +| Lens tab (optical/film effects) | `.../panel_lightbox_lens.xml` | +| Scene tab (quality/performance) | `.../panel_lightbox_scene.xml` | +| Sky tab (environment + client-side sky effects) | `.../panel_lightbox_sky.xml` | +| Day cycle landmark search | `indra/newview/aldaycyclelandmarks.{h,cpp}` | +| The C++ (callbacks, Vec3 binder, section reset, Looks bar) | `indra/newview/alfloaterlightbox.{h,cpp}` | +| Looks preset system + whitelist | `indra/newview/llpresetsmanager.{h,cpp}` | +| Bundled starter Looks | `indra/newview/app_settings/looks/` | +| Colour wheel widget + its maths | `indra/newview/alcolorwheel{ctrl,model}.{h,cpp}` | +| Curve/band graph widget + its maths | `indra/newview/alcurve{editorctrl,model}.{h,cpp}` | +| Scopes floater + measurement | `indra/newview/alfloaterscopes.{h,cpp}`, `alscopedata.{h,cpp}` | +| Scope pane assignment menu | `.../menu_scopes_pane.xml` | +| Undo/redo stack | `indra/newview/algradehistory.{h,cpp}` | +| White-balance map and its inverse | `indra/newview/alwhitebalancesolver.{h,cpp}` | +| Scene colour picker tool | `indra/newview/altoolscenepicker.{h,cpp}` | +| 3D LUT (.cube) parser | `indra/newview/lutcube.{h,cpp}` | +| Header checkbox on `accordion_tab` | `indra/llui/llaccordionctrltab.{h,cpp}` | +| Anti-aliased 2D polyline and fill | `indra/llrender/llrender2dutils.{h,cpp}` | + +Seven of those have unit tests, and the tests are the reason the maths in them +can be trusted: `alcolorwheelmodel_test`, `alcurvemodel_test`, +`aldaycyclelandmarks_test`, `algradehistory_test`, `alscopedata_test`, +`alwhitebalancesolver_test`, `lutcube_test`. Anything with arithmetic in it — +including a parser fed files from the internet — belongs on that list. + +### What v2 added, and what it altered + +Two of these are new XUI tags, one is a new param on a stock widget, and one is a +new pair of drawing primitives everything else is painted with. Nothing else in +the viewer's widget set was touched. + +| Thing | Written as | Where | +|---|---|---| +| Colour wheel | `` | §4b | +| Curve / band graph | `` | §4b | +| Checkbox on an accordion header | `` | §3b | +| Anti-aliased polyline and area fill | `gl_polyline_2d`, `gl_polyfill_2d` | §4b | +| Floater top bar (Looks, history, scopes) | ordinary buttons, `Floater.Toggle` | §4g | +| Scopes window | its own floater | §4d | +| Sky tab (day cycle freeze) | ordinary rows, `LLEnvironment` behind them | §4h | + +`accordion_tab` is the only **stock** widget altered, and the change is additive: +a tab that does not ask for `header_check_box` gets exactly the header it always +had. That mattered rather a lot — 26 other files in the English skin alone +declare accordion tabs, 75 of them, and every one is outfit editing, profiles, +preferences or the About box. Which is the standard to hold anything else here +to: if the Lightbox needs something from a shared widget, it asks for it by an +optional param and leaves the default behaviour alone. + +**The C++ does not grow per effect. It does grow per new *kind of control*.** +That is the real contract, and the distinction matters when you plan work: + +- Exposing another slider, checkbox, dropdown, colour or vector row is still + pure XUI. Nothing below the line changes. +- Introducing a control that has never existed here before — a wheel, a graph, + an eyedropper — is a widget plus, usually, a floater-side callback. Those are + written once and then reused by name like any other row. + +The floater's C++ provides: + +- `LightBox.ResetControlDefault` — per-row reset; `parameter` = setting name. +- `LightBox.ResetSection` — data-driven section reset; `parameter` = `sec_`. + It walks the panels named `sec_` and `sec__adv`, collects every + descendant's bound control (plus settings named by `vec3_*` spinners), and + resets them. New rows enroll automatically. +- `LightBox.CommitVec3` — the component binder for Vector3/Color3 settings, + driven entirely by the widget naming contract `vec3__<0|1|2>`. + `postBuild` discovers the widgets, seeds them, and keeps them synced with + the control both ways. Partial exposure is supported: expose only the + meaningful components and the binder preserves the rest on write. + **Any `LLUICtrl` will do** — the name is the whole contract, so a bank of + related values can be sliders rather than spinners. Prefer sliders for a + bank: eight hue sliders in a column read as a shape, and you can see at a + glance that the warm end has been pulled and the cool end left alone. Eight + spinners read as a form to be filled in. +- `LightBox.CommitToneCurve` / `LightBox.RefreshToneCurve` — the tone curve + graph's handle commit and its channel-selector refresh. +- `LightBox.CommitSplitToneGraph` — the split-tone band graph's handle, which + writes `RenderSplitToneBalance`. +- `LightBox.PickWhiteBalance` — arms the eyedropper. +- The Looks bar and tonemapper-row greying (effect-specific, already done). + +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 +the settings' signals and calls `refreshX`; `refreshX` rebuilds the plot and +handles from the settings; `onCommitX` writes the setting and calls `refreshX` +again behind a re-entry guard. Copy that shape rather than inventing another. + +## Adding a section: step by step + +### 1. Declare the settings + +Add keys to `indra/newview/app_settings/settings_alchemy.xml` with a good +`Comment` — write the valid range and what values mean into it. The comment is +the source for the row's `min_val`/`max_val`/tooltip, and Debug Settings shows +it too. Match the code's defaults if the setting is read with an inline +fallback (an undeclared key read by `LLCachedControl` works but does not +persist — declare everything you expose). + +### 2. Choose the tab and shape + +- **Look** = color science (tone, grading). **Lens** = optical/film effects. + **Scene** = render quality and performance. **Sky** = the sky being shot: + client-side sky effects, which are ordinary settings rows, plus the day cycle + controls, which are the odd ones out because they reach past this floater and + change the world's environment. Read §4h before adding day cycle controls; + a sky *effect* needs nothing special. +- Essentials (2-4 knobs) in the main section; long tail in a *sibling* + accordion tab named `atab_sec__adv` titled `"
- Advanced"`. +- **Fold instead of splitting** when the advanced tail is small (~2-3 rows) or + the essentials are trivial (one slider) — one section, no sibling. +- Enum-valued settings are dropdowns, never bare int sliders. Sliders whose + change handler reallocates GPU resources per tick should also be dropdowns + with a few sane steps (see shadow resolution scale: x2/x1/x0.5). +- Multi-component settings get semantic labels, not X/Y/Z (see the SSAO + "Occluded value"/"Occluded saturation" rows). +- Debug-only toggles (dither, buffer formats, GL context flags) stay out of + the floater; Debug Settings is their home. + +### 3. Copy the section skeleton + +```xml + + + + + + + +``` + +The essentials section's Reset All resets the Advanced sibling too (the walker +includes `sec__adv`); the sibling's own button uses `parameter="sec_myfx_adv"`. + +### 3b. A switch on the section header (optional) + +`accordion_tab` takes an optional `header_check_box`, drawn at the right end of +the header. It is a full `check_box` params block, so `control_name`, +`enabled_control`, `tool_tip` and `commit_callback` all behave as they do +anywhere else: + +```xml + + + + + +``` + +Note `` and not ``: inside a nested +params block the dotted prefix has to be the *block's* name, so plain +`` is what resolves. `` works +too, and `` is silently ignored. + +Omit the block and there is no checkbox — not a hidden one, none at all — which +is what keeps every other accordion in the viewer exactly as it was. Four things +worth knowing before you use it elsewhere: + +- **Size.** It defaults to a bare 24×16 box. A header checkbox carrying a + `label` has to state its own `width`, because nothing measures the text for + you. The title ellipses at the checkbox's left edge either way. The area that + actually takes the click is `LLCheckBoxCtrl`'s bounding rect — the box and + label, not the full rect — so a press inside the rect can still be refused; + the tab falls through to expand/collapse when that happens rather than + leaving a dead ring of pixels. +- **The tab eats mouse-downs.** `LLAccordionCtrlTab::handleMouseDown` claims the + whole header band and toggles expand/collapse *before* any child is offered + the press, so a control living there is unreachable by default. + `pointInHeaderCheckBox` is the exemption; anything else you add to a header + needs the same treatment. +- **The tab eats tooltips too**, and worse: `handleToolTip` forwards the header + band to `mHeader` without converting coordinates, so a header child's own + tooltip is never found once the tab is expanded. Same exemption, and it does + convert. +- **No `control_name` for a comparison switch.** See the bypass note in + *Architecture in brief*: a whitelisted setting toggled to compare dirties the + active Look. A header checkbox that *is* a setting — the grading master — + takes `control_name` as normal, and `LightBox.ResetSection` reaches it: the + walker checks `atab_
`'s header checkbox as well as the panel, so a + bound switch on a header is still covered by that section's Reset All. + +### 4. Rows + +First row uses `top="8"`; later rows chain with `top_pad`. Every value row gets +an 18px reset glyph. Copy these verbatim and edit names/keys/ranges: + +**Scalar (slider) row** — `top_pad="9"` between slider rows: + +```xml + + +``` + +**Checkbox row** (no reset glyph): `check_box` with `control_name`, +`top_pad="10"` after a button, `top_pad="8"` after another checkbox. + +**Enum dropdown row**: label `text` (width 140) + `combo_box` at +`left_delta="140" top_pad="-16" right="-32" height="18"` with integer +`combo_box.item` values + reset button at `top_pad="-18"`. Float-valued combos +work only with values whose `%lg` stringification is exact ("2", "1", "0.5"). + +**Color row** (0-1 tints only): label `text` (`top_pad="12"`) + `color_swatch` +at `left_delta="140" top_pad="-18" width="60" height="24"` with +`can_apply_immediately="true"` and **`label_height="0"`** (without it the +default label strip leaves ~1px of color) + reset at `top_pad="-21"`. Color3 +alpha handling lives in the widget — nothing else needed. + +**Vector row**: label `text` (width 110, `top_pad="10"`) + spinners named +`vec3__<0|1|2>` at `left_delta="110" top_pad="-16" width="68" +height="18"` (then `left_delta="72" top_delta="0"`), each with per-axis +`min_val`/`max_val` and ``, ++ one reset glyph (`top_delta="0"`, parameter = the setting). Setting names +contain no underscores, so the name parse is unambiguous. Omit components that +are unused — label the ones you keep by meaning. + +A **bank** of the same component across many settings is the other shape this +supports, and it wants ordinary slider rows rather than the compact spinner +layout: one section per component, one slider per setting, named +`vec3__` with a ``. Nothing but XUI is +involved either way. + +**Group headers** (inside a long Advanced panel): a `view_border` +(`bevel_style="none" height="0"`, `top_pad="12"`) then a bold `text` +(`font="SansSerifBold"`, `top_pad="6"`); the first group needs no border. + +### 4b. The richer widgets + +Two widgets exist beyond the standard rows, and each is one XUI tag. (The third +richer control, the switch on a section header, is §3b — it is a param on +`accordion_tab` rather than a tag of its own.) + +**`color_wheel`** — a hue ring with a draggable puck, a master slider and three +editable channel fields, all driving one Vector3 setting. + +```xml + +``` + +- Binds through plain `control_name`, **not** the `vec3_*` contract: the widget + handles a three-element LLSD array in `setValue`/`getValue`, which is all + `LLUICtrl::setControlVariable` needs to wire both directions. +- `centre`/`min_value`/`max_value` describe the setting: lift is 0 over + [-0.5, 0.5]; gamma and gain are 1 over [0.5, 1.5]; a split-tone tint is 0.5 + over [0, 1] **plus `lock_master="true"`**, because the renderer divides each + tint by `dot(tint, LUMA)` so its magnitude cancels — a master there would be + a control that does nothing. +- A bank of three at 120px wide with a 4px gap fits the accordion at the + floater's minimum width. Lefts 8 / 132 / 256. +- The rest of its params are appearance and have working defaults you should + need to override only for a genuinely different control: `ring_thickness`, + `ring_steps` (segments the ring is drawn in), `puck_radius`, `decimal_digits` + for the three channel fields, and `border_color` / `face_color` / + `crosshair_color`. `label` names the wheel above the ring. + +**`curve_editor`** — a graph with draggable handles, used for the tone curve and +the split-tone bands. It owns no curve: a consumer hands it a sampling function +and a handle list, which is why one widget serves both. + +```xml + + + +``` + +- `setCurve` plots one solid curve, `addGhostCurve` dimmed references, + `addFillCurve` a filled area down to y=0 for coverage plots like the bands. +- Handles carry `mLockX`/`mLockY`. **Read those as "this axis cannot change".** + A handle that slides horizontally to set a value locks *Y*. +- `draw_diagonal="true"` draws the identity, which is meaningful for a tone + curve and meaningless for anything else. +- `grid_divisions` sets the backing grid; `curve_samples` how finely the + sampling function is evaluated across the width; `handle_radius` and + `curve_width` the hit target and the stroke. Colours are `background_color`, + `border_color`, `grid_color`, `curve_color`, `handle_color`. + +**Graph maths belongs in the model, next to a test.** `ALCurveModel` mirrors the +shader's own functions, and `alcurvemodel_test` transcribes the GLSL +independently and compares. A graph that merely illustrates the shader is worse +than none: it will be believed. If you plot something new, transcribe it. + +**The two of them draw with `gl_polyline_2d` and `gl_polyfill_2d`** +(`llrender2dutils`), added for this work and available to anything else that +plots. Use them rather than reaching for `LLRender::setLineWidth` and +`GL_LINE_SMOOTH`: smoothing appears nowhere else in this tree, core profiles +routinely ignore it, and `setLineWidth` clamps to `mAliasedLineRange`, which is +`[1,1]` on most core drivers — so neither width nor smoothing can be relied on +from the fixed pipeline. The polyline lays a ribbon of triangles with a +one-pixel alpha falloff and mitred, clamped joins, so a curve has no notches at +its vertices and a hairpin is blunted rather than shot off to infinity. + +The fill's edge is deliberately left aliased. Pass it the translucent colour +such a fill wants and outline it separately if you need a crisp edge; a feathered +fill under a feathered outline doubles the coverage along the shared path and +draws a darker seam. + +### 4c. Tools + +Four controls act on something other than a setting. None of them writes to +`gSavedSettings`, and that is the point of grouping them: each parks state +somewhere else, which is a liability the ordinary rows do not have. + +- **Eyedropper.** A `button` calling `LightBox.PickWhiteBalance` installs + `ALToolScenePicker` as a transient tool. On mouse-up it asks + `LLPipeline::requestScenePixel`, which reads the linear scene buffer in + `renderFinalize` **before** `colorCorrect` and answers on the next frame. + `ALWhiteBalanceSolver` inverts the renderer's own temperature/tint map — that + map lives in the solver and pipeline.cpp calls it, so the two cannot drift. + Sample somewhere else and reuse the same route; do not add a second readback. +- **Hold-to-compare.** `LLPipeline::sGradeBypass`, driven by the bindable + `grade_bypass_key` action. If you add another temporary "show me without it" + affordance, follow the same rule: **never toggle a whitelisted setting to do + it**, or the active Look goes dirty and can be saved mid-comparison. + Registered *global*, which it has to be — a normal in-world action does not + fire while the Lightbox has focus, and that is exactly when you want the + comparison. The cost is that global bindings are dispatched before view + handling and are non-consuming (`llviewerwindow.cpp`, "like voice"), so they + fire while a text field has focus too. Bind it to a function key or a mouse + button; a letter would flash the grade every time that letter is typed. +- **Per-section bypass.** `LLPipeline::sGradeBypassMask`, one bit per group, + driven by the `LightBox.ToggleSection` checkbox on each section's accordion + header. Ticked is the section switched **on**, because that is the only way a + box beside a section title reads; `onToggleSection` inverts, since the bit it + drives suppresses. A set bit makes `colorCorrect` upload that group's + **identity** values instead of its settings, which lands in the early-out the + shader already has for that step — so this needed no new uniform, no new + variant and no recompile, and a bypassed section costs slightly *less* than an + active one. If you add a grading step, add its identity to that block or the + bypass will quietly skip it. + + The groups match Reset All's grouping (`sec_` plus `sec__adv` + together), so a section with a long tail in an Advanced sibling is one switch + and one idea of "a section". Put the checkbox on the essentials tab only; the + Advanced sibling is the tail of a section, not a section. + + These lived as a row of five in the Color Grading section until it turned out + that the objection to putting them on the headers — that A/B work means + flipping between them, and hunting through collapsed accordions is worse than + one row — was answered by the header itself. A collapsed accordion still shows + its header, so a header checkbox is visible in every state the section has, + and it is beside the controls it suppresses instead of a scroll away from + them. All five headers are in view at once whenever their sections are shut, + which is the state A/B work is done in anyway. + + They carry no `control_name` on purpose. Every grading setting is on the Looks + whitelist, so a comparison built out of one would dirty the active Look and + could then be saved mid-comparison; and since the floater is destroyed on + close (see below), a fresh one comes back with all five ticked, which is what + makes "clears when the Lightbox closes" true without any code to do it. +- **Reference still.** `LLPipeline::requestReferenceStill` grabs the frame about + to be presented; `RenderReferenceWipeMode` then wipes the live image against + it. Where hold-to-compare shows you *no* grade, this shows you the grade you + had ten minutes ago, which is the comparison that matters once a look has + taken more than a moment to build. + - Grabbed at the same point the scopes sample — after every post pass, before + the print effects — and substituted **before** those effects in + `blitWithEffectsF.glsl`, so vignette and grain land on both sides of the + seam. The comparison is then about the grade rather than the print + treatment. + - The mode is forced to zero unless a still exists, which is what lets the + shader sample the reference without checking. + - Both settings are `Persist=0`: a still cannot outlive the session, so a + mode that did would come back pointing at nothing. + - A resize drops the still. Sampling a still of one resolution against a + frame of another would stretch it, and a reference you cannot trust + geometrically is worse than none. + +The first two reach across a frame or a keypress; the last two park state that +outlives the floater. **Anything deferred like that must capture an `LLHandle`, +never `this`** — the Lightbox declares neither `single_instance` nor +`reuse_instance`, so closing it *destroys* it, and an armed picker holding a raw +pointer is a use-after-free with no window of luck involved. + +**And anything that parks state outside the floater must clear it in the +destructor.** The bypass mask lives in the pipeline; left set, a closed Lightbox +would leave a section suppressed with nothing on screen to say so and no setting +to inspect. That is harder to diagnose than a crash. The reference still is the +same rule with a second reason — it is a full-resolution target, so leaving one +behind holds real memory for a comparison nobody can see or switch off any more. +`~ALFloaterLightBox` clears all three: the armed picker, the bypass mask, and +the still along with its wipe mode. + +That the destructor is enough turns on the floater being destroyed on close, so +do not "tidy up" by declaring `single_instance` on it without moving this +cleanup to `onClose` first. It would keep every one of these switched on behind +a closed window. + +### 4d. Scopes + +The Scopes floater is a separate window on purpose — a scope inside a Lightbox +tab is hidden behind whichever tab you are editing. `ALScopeData` does the +measuring with no GL or UI, so `alscopedata_test` can exercise it: four +histogram channels, a two-dimensional chroma grid for the vectorscope, and a +per-column grid for the waveform and parade. + +Each scope answers a different question, and that is the reason to have three: +a histogram says **how much** of the frame is at a level, a waveform says +**where** it is (a blown sky and a blown face are the same histogram bin and +obviously different waveforms), and a vectorscope says **what colour**. + +Because they answer different questions, the floater shows up to four at once. +`AlchemyScopeLayout` picks the arrangement (single, two side by side, two +stacked, four in a grid) and `AlchemyScopePane0` through `AlchemyScopePane3` +say what each pane holds; right-clicking a pane sets its own. `computePaneRects` +is the single place that divides the plot area, and it returns rects in the +floater's coordinate space — the same space `draw()` paints in and +`handleRightMouseDown` is handed — so the rect that drew a pane is the rect +that hit-tests it. If those ever diverge, the menu opens on the wrong pane. + +The draw functions therefore take `(mode, rect)` rather than reading the mode +and the panel themselves. Anything new must too: a scope that reaches for +`mPlotPanel->getRect()` draws over all four panes. + +Its XUI is deliberately thin, because almost none of that window is widgets. A +`combo_box` bound to `AlchemyScopeLayout`, a `check_box` on +`AlchemyScopeLogScale`, an empty `panel` named `scope_plot` that the scopes are +painted into, and a `text` for the clipping readout. Everything else is drawn. +The nine pane labels are `floater.string` entries named `mode_*` rather than +literals, so they translate, and `menu_scopes_pane.xml` is a `context_menu` of +`menu_item_check` rows wired to `Scopes.SetPaneMode` / `Scopes.IsPaneMode`. + +**Adding a scope is three edits, not one.** A new `EMode` needs an entry in +`modeStringName`, a `floater.string` for its corner label, and an item in +`menu_scopes_pane.xml` — the menu is hand-written rather than generated from the +enum, so a mode added without one is reachable only by editing the setting. +Keep `MODE_COUNT` last: `getPaneMode` clamps against it, and that clamp is what +stops a stale setting from indexing off the end. + +**A button that opens another floater wants `Floater.Toggle`.** Not +`Floater.ToggleOrBringToFront`, which is written for toolbar buttons: it closes +its target only after falling through `else if (!instance->isFrontmost())`, and +pressing a button inside a floater makes *that* floater frontmost, so the close +branch is unreachable. The button then opens and raises its target and can never +shut it. Both are global commit callbacks registered in `llui.cpp`, so such a +button needs no C++ at all. + +**Spawn an `LLContextMenu` with `show()`, never `LLMenuGL::showPopup()`.** +`LLContextMenu` overrides `setVisible` to ignore everything except `false`: + +```cpp +void LLContextMenu::setVisible(bool visible) { if (!visible) hide(); } +``` + +`showPopup`'s only attempt to reveal a menu is `setVisible(true)`, so against a +context menu it does nothing — silently. The menu still loads, parents, +populates and resolves its callbacks, so there is no warning in the log and +nothing to find at the point of failure; it simply never appears. Copying the +spawn code from a view that uses a plain `LLMenuGL` (`llnetmap` is the obvious +one to reach for) walks straight into this, because that call is correct +*there*. `LLContextMenu::show` also does its own arranging and edge-flipping, +so it replaces `showPopup` rather than joining it. + +Its coordinates are **screen** space — it calls `screenPointToLocal` internally +— while `handleRightMouseDown` is handed coordinates local to the view. Convert +with `localPointToScreen` or the menu opens in the wrong place on any window +that is not at the screen origin, which is easy to miss when testing maximised. + +Two rules if you add another: + +- The vectorscope bins through `ALColorWheelModel::toChroma`, the same basis the + wheels edit. That is load-bearing: push a wheel and the trace must move the + same way. Go through that function rather than copying the basis. +- Anything per-column must normalise by **that column's own pixel count**, not + by the sample height. The sample's width rarely divides the grid evenly, so + columns cover unequal numbers of source columns, and a sample narrower than + the grid leaves some empty. + +Two costs to know about. The waveform grid is a quarter of a megabyte, so it +lives in a `std::vector` and not in the object — `captureScopeSample` builds a +whole `ALScopeData` as a **stack local**, and a member array that size would put +it on the stack every capture. It is also empty until something is measured, so +a viewer whose scopes have never been opened pays nothing. + +Note what the pane layout does *not* cost. `accumulate` fills every channel, the +chroma grid and the waveform grid on each capture whatever is displayed, so a +fourth pane adds drawing and nothing else — no extra sampling, read-back or +binning. Drawing is where it is lopsided: a histogram is `BIN_COUNT` bins, but a +waveform is `WAVE_COLUMNS * WAVE_LEVELS` cells **per channel**, so a parade pane +is worth roughly two hundred histograms. Four is the ceiling for that reason, +not because the tiling could not go further. + +And capture is gated on the floater existing (`LLPipeline::sScopeCapture`), so a +closed window costs nothing at all. That gate is worth reusing: the cursor +readout gets its value from `LLPipeline::getScopePixel`, which is a lookup into +the sample `captureScopeSample` already took rather than a second readback. If +you want a value off the frame and can accept it being one sample interval old +and point-decimated, take it from there rather than adding another `glReadPixels`. + +### 4e. Adding a grading step to the shader + +The post shaders are separate GL shader objects linked together: +`colorCorrectF.glsl` calls helpers that live in `colorGradeUtilF.glsl`, +`tonemapUtilF.glsl` and `postEffectUtilsF.glsl`. **GLSL requires a declaration +before use within each object**, so a new helper needs *two* edits to the +caller: the call in `main()`, and a forward declaration in the block at the top. + +Forget the declaration and the file does not compile — **at runtime, on the +user's machine**. The C++ builds clean, every unit test passes, and the viewer +then fails to link the shader, binds a null program and dies on the first frame +with an access violation in `LLGLSLShader::bind`. There is no build-time signal +at all; this has happened. + +So: after touching any `*F.glsl` under `shaders/class1/alchemy/`, launch the +viewer — a shader that fails to compile or link says so in the log before the +frame dies, and that log line is the only automated check there is. Nothing in +`cmake --build` or `ctest` covers shaders, and there is no offline checker in +the tree; if you write one, commit it under `scripts/` and name it here. + +**A new uniform is the other half of this, and it fails even more quietly.** +`LLShaderMgr::mReservedUniforms` maps an enum index to a name *string*, and +nothing checks that string against the shaders. A typo, or an array written as +`uThing[0]`, produces no error at all: `mapUniform` never records a location, +every upload silently does nothing, and the shader reads the uniform as zero. +That surfaces as a rendering fault — a black screen, in the case that prompted +this — a long way from the cause. + +Two rules follow. **Declare an array uniform by its bare name**, with no `[0]`: +`mapUniform` strips the subscript from whatever GL reports before matching, so a +name carrying one can never match. (`initAttribsAndUniforms` now refuses such a +name outright, next to the size-sync and duplicate checks.) And after adding an +entry, **grep the shaders for the exact string you put in the table** — a name +no shader declares produces no error anywhere, only zero-filled uniforms, so +the grep is the whole check and there is no tool that does it for you. + +### 4f. Undo + +`ALGradeHistory` (tested by `algradehistory_test`) plus the wiring in +`ALFloaterLightBox`. **A new grading setting gets undo for free by being on the +Looks whitelist** — the floater watches exactly `getLooksControlNames()`, on the +principle that a setting worth saving into a Look is one worth undoing, and one +list means a new control cannot join one and miss the other. + +The recorder needs no shadow copy of the settings: `LLControlVariable`'s commit +signal carries the **previous** value as its third argument, and only fires when +the value actually changed (`setValue` and `resetToDefault` both gate on +`llsd_compare`), so a commit that rewrites the same value cannot leave a +do-nothing step on the stack. + +Two rules if you add a control: + +- **One user gesture must be one step.** A drag emits a commit per mouse-move, + and the history collapses those by coalescing successive writes *to the same + control* inside 500 ms. Every interactive control here writes exactly one + setting per commit — the tone-curve graph picks toe *or* shoulder *or* + strength, the band graph writes only balance — which is what makes that + enough. **A control that wrote two settings per commit would defeat it**, and + produce one undo step per setting per mouse-move. If you need one, the fix is + in `ALGradeHistory`, not in the caller. +- **A discrete action that moves many controls needs `ScopedHistoryGroup`.** + Reset All, applying a Look and Look revert each wrap one, so they undo in a + single step. Note that a group deliberately does *not* coalesce, so it must + only ever wrap a discrete action — wrapping a per-move commit in one would + give you back the hundred-step drag. + +Undo restores **values only**. The active Look stays dirty, exactly as it would +had the user typed the old numbers back in; restoring that faithfully would mean +modelling the Looks system's history too. The stack is a floater member, so it +dies with the window rather than outliving it to rewrite a later session's edits. + +Ctrl+Z / Ctrl+Y / Ctrl+Shift+Z are bound in `handleKeyHere`, **floater-local and +not a global action** — the opposite choice from hold-to-compare, and for the +opposite reason: a global Ctrl+Z fires while the user is typing anywhere in the +viewer. Being handled there also means a focused text field keeps Ctrl+Z for its +own undo, since the focus chain is offered the key first. + +The Undo and Redo buttons in the top bar are the visible half of that. Both, and +the reference row, are greyed from `draw()` rather than from a signal — the undo +stack moves on every commit, every undo and every Look apply, and hanging a +refresh off each of those is more places to forget than a polled compare costs. + +### 4g. The top bar + +`lightbox_topbar` in `floater_lightbox_settings.xml` is an ordinary `panel` of +ordinary widgets, and worth reading before you add to it, because it is the one +part of this floater with a fixed width budget: **412px at `min_width`** — the +floater's 420 less its own `left="4"`/`right="-4"`, and nothing else, because +the bar sits directly in the floater. A row inside an accordion section starts +from the same 420 and loses far more (§5); the two budgets are different on +purpose. The bar currently spends 342 of its 412. + +Left to right: the Looks `combo_box`, then Save / Save As / Delete / Revert, +then Undo / Redo, then Scopes. Three groups, separated by 12px where the +adjacent buttons inside a group are separated by 4. **That gap is the only thing +that says they are different kinds of thing** — the first group acts on the +Look, the second on the grade's own history, the third opens another window — so +keep it if you add a fourth kind, and use 4px if you are extending a group. + +Everything after the combo is an **18px icon with an empty label**, and the +tooltip carries the name. That is not decoration. Four text labels cost 192px of +the 412; the same four icons cost 80. It also sidesteps §5's silent clipping the +day this floater is translated and "Save As" becomes "Speichern unter" — a bar +of labels has no reflow and no scrollbar to save it. Take the overlays from the +viewer's existing set (`Script_Save`, `Conv_toolbar_plus`, `TrashItem_Off`, +`Refresh_Off`, `Script_Undo`, `Script_Redo`, `Command_Stats_Icon`) rather than +adding art; picking a glyph that already means the right thing elsewhere is most +of the work. + +**A button that opens another floater needs no C++ at all** — `Floater.Toggle` +is a global commit callback in `llui.cpp`, with the floater's registered name as +`parameter`. Use that one and not `Floater.ToggleOrBringToFront`; §4d explains +why the second can only ever open. + +### 4h. The Sky tab, and changing the world + +The Sky tab holds two kinds of thing and they do not behave alike. + +**Sky Effects is ordinary settings rows** and wants nothing from this section: +`control_name`, a reset glyph, a Reset All, done. Aurora, meteors and the star +field are drawn entirely on the client, so nothing is sent to or from the region +and no land setting turns them on — which is what makes them safe to expose here +at all, and why they sit beside the day cycle rather than in Scene. All three +gate on the same star brightness the night sky uses, so they simply do not +appear in daylight, and an HDRI sky replaces the dome and leaves nothing to draw +into. None is on the Looks whitelist: a Look is the aesthetic settings of the +Look and Lens tabs, and a Look that switched the aurora on would be a surprise. + +Two shaping decisions in that section are worth copying. It is **one** section +rather than three because two of the effects are a single control each, and a +section per slider reads as filing rather than grouping. And the star count is a +**dropdown**, by §2's rule: committing it regenerates every star position and +rebuilds the vertex buffer, so a slider — which commits on every mouse-move — +would hitch the whole way across its own travel. + +**Day cycle is the odd one out.** It changes `LLEnvironment`, which is shared +with the whole viewer, and that makes it a different kind of thing to work on. +Six rules, all learned the hard way and all still true for anything else that +reaches out of this floater. + +**There is no clock to stop.** `DayInstance::getProgress()` computes the cycle +position from `LLDate::now()` plus the day offset, every frame, so nothing can +be paused. "Freeze" means sampling the running cycle at one position and +installing the result as a *fixed* local environment; the motion stops because +there is no longer a day cycle in effect. That is also how `@setenv_daytime` +and the day cycle editor's timeline do it — sample with +`LLTrackBlenderLoopingManual(target, day, track)->setPosition(0..1)`. + +**Sample water as well as sky.** Track 0 is water and the sky tracks are 1 to 4, +chosen by altitude via `calculateSkyTrackForAltitude`. RLVa's version freezes +only the sky, and a frozen sky over a moving sea is not frozen. + +**RLVa is enforced below you, not by you.** `setSelectedEnvironment` returns +early when `!RlvActions::canChangeEnvironment()`, inside `LLEnvironment`. A +control that does not check it looks live and silently does nothing under +`@setenv=n`, so the Light rows are greyed from the same `draw()` poll that reads +their state back. There is no `enable_callback` on ordinary widgets in XUI — +menus have `on_enable`, widgets do not — so this has to be done in C++. + +**Freezing covers up whatever `ENV_LOCAL` held**, which is where Personal +Lighting and an inventory-applied sky live. Unticking Freeze puts back what was +captured on the way in; "Restore region environment" is the unconditional way +out and *does* discard it. Clearing without capturing first is a silent way to +lose someone's sky. + +**Reverting the sky invalidates every reflection probe** that was lit by it. +`gPipeline.mReflectionMapManager.reset()`, the same call the World menu's own +revert makes. + +**Read the state from the world, not from a mirror.** Whether the sky is frozen +is `getEnvironmentFixedSky(ENV_LOCAL) != nullptr`; the cycle to scrub is the +first day found across `ENV_LOCAL`, `ENV_PUSH`, `ENV_PARCEL`, `ENV_REGION`. +Deriving both means the tab stays honest when the World menu, an attachment or +another floater changes the environment underneath it — and it is what lets +scrubbing survive closing and reopening the floater, since nothing about the +freeze is remembered in the floater at all. + +#### A cycle position is not a time + +Nothing in this viewer maps a cycle position to a clock. The day cycle editor +labels its timeline as a **percentage**, and a region can put its keyframes +wherever it likes, so "noon is 0.5" is a property of some day cycles and not +others. Even the stock day is not what you would guess: `LLSettingsSky::defaults` +computes sun altitude as `π × position`, and caches its result in a `static`, so +the viewer's own default day cycle is eight identical frames. + +So the four preset buttons do not use fractions. `ALDayCycleLandmarks::find` +samples the cycle, reads `getSunDirection().mV[VZ]` — the sun's height above the +horizon — and takes noon and midnight from the extremes and sunrise and sunset +from the horizon crossings, interpolated between samples so the answer beats the +grid. A cycle without a given landmark reports it absent and the button greys, +because a sun that never sets has a noon and no sunrise, and inventing one would +be worse than offering nothing. + +It takes a sampler rather than a day cycle, the same shape `curve_editor` uses, +which is what lets `aldaycyclelandmarks_test` exercise it with a sine wave and +no viewer around it. Sampling costs ninety-six blends, so it is cached against +the day it was computed from and never runs on the frame path. + +### 5. Height math (the part everyone gets wrong) + +- `accordion_tab` height **must be** inner panel height **+ 29** + (25px header + 2+2 padding). The tab's rect *is* its expand height and + `fit_panel` squeezes the panel into what remains; an undersized tab clips + the bottom rows and the overflow draws over the sections below (panels do + not clip children). +- Compute the panel height by walking the `top_pad` chain to the **last + widget's bottom**, then add 8. `top_pad` chains from the *previous widget*, + which for a slider row is its reset button (18px tall, hanging 1px below the + 16px slider) — so slider+reset rows pitch **26px**, not 25. +- Worked example: slider row at `top="8"` (slider 8-24, button 7-25), second + slider `top_pad="9"` (34-50, button 33-51), Reset All `top_pad="10"` + (61-79) → panel height 87, tab height 116. +- **Side-by-side widgets break the chain.** A bank of three wheels all use + `top="8"`, so the bank's bottom is *one* wheel's height, not three; the next + row's `top_pad` chains from the last one declared. Get this wrong and the + panel is either 350px too tall or clipped. +- **A row of buttons has to be sized for `min_width`, not for the default.** + The arithmetic that matters is 420 − 28 for the chrome − 15 for the accordion + scrollbar − 16 for `left="8"`/`right="-8"`, which leaves **361px**. The Light + tab's four presets are 85 wide with 6px gaps and end at 366 of 369. Laid out + against the 460 default they looked fine and lost their last button the + moment the floater was narrowed. +- Widths never reflow. Usable inner width is the floater's width − 28, and + ~15px less again whenever the accordion's scrollbar shows. **Overflow clips + silently, with no scrollbar and no warning**, so check the narrowest case: + the floater at its `min_width` *with* the vertical scrollbar visible. +- `min_width` is the only declarative way to widen an existing user's floater + (`LLFloater::applyRectControl` prefers a saved rect over the XUI width), and + it force-widens everyone permanently. Design to the current width instead. +- **All of this is checkable without launching**, and worth checking that way + because the failure is silent: walk each `sec_*` panel's children, track + `top = prev_bottom + top_pad` (or the absolute `top`), and assert the panel's + declared height is the lowest bottom + 8 and the tab's is the panel's + 29. + Thirty lines of Python over the XUI, and it catches the arithmetic slip that + otherwise shows up as a row you cannot see. + +### 6. Gating + +`enabled_control="SomeBool"` / `disabled_control="SomeBool"` on each dependent +widget greys it live (they connect to the control's signal). Boolean controls +only; apply per row, not on the parent panel. Reference patterns: + +- HDR fork: bloom rows `enabled_control="RenderHDREnabled"`, legacy glow rows + `disabled_control="RenderHDREnabled"` — greying, not visibility, so the + layout never gets holes and both modes stay discoverable. +- **`RenderColorGrade` is the master switch for the entire grading suite** + (LUT *and* Basic, White Balance, Split Toning, Lift/Gamma/Gain, Tone Curve). + Any new grading control must gate on it or it will look inert. It lives on + the Color Grading accordion's own header, as a `header_check_box` with + `control_name` — so it is legible and throwable whether or not the section is + open, and it reads the same way as the five section switches beneath it. Note + what tells the two kinds apart: the master has a `control_name` because it is + a setting, the section switches have none because they are a viewing state. + That section is also the only one that opens by default, which is why its + body is kept to one line of text plus the reference still. +- Int-selected modes can't gate declaratively; either leave rows enabled with + a "(X only)" tooltip or add a small signal handler like + `updateTonemapperRows()`. +- **`gSnapshotNoPost` gates the renderer, not the UI, and it is easy to miss.** + 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, + which is a quantisation aid rather than a look and which an 8-bit PNG wants + either way. + +Two things that only show up on screen, both of which did: + +- **A one-line `text` needs `height="16"`, not `height="30"` with `word_wrap`.** + Three lines of prose in a 30px box do not scroll or ellipse, they draw over + the row beneath. Count the characters: roughly 70 fit on a line at + `SansSerifSmall` across a section at `min_width`. +- **A button's `width` has to fit its own label**, and an `image_overlay` eats + 18px of it before the text starts. There is no reflow and no ellipsis; the + label is simply cut. Prefer a name the viewer already uses — "Use shared + environment" is the World menu's own wording for dropping a local environment + — over a longer one you invent. + +### 7. Slider text width + +Any slider with `max_val` below 1.0 (or ≤ 0) **must** set an explicit +`text_width` (56 fits a signed 4-decimal value). Without it `LLSliderCtrl` +auto-sizes the value box from `log10(max_value)` and truncates the number. + +### 8. Cadence and tooltips + +- Most keys are read per-frame: live preview, nothing to say. +- Keys wired to reallocation/rebuild handlers in `llviewercontrol.cpp` still + apply automatically but hitch — say so: "Changing causes a brief hitch." / + "Toggling rebuilds shaders (brief hitch)." +- Keys with **no** handler need a restart note, or better, don't expose them. +- Check with: `grep indra/newview/llviewercontrol.cpp`. + +### 9. Looks whitelist + +If the new effect is **aesthetic** (Look/Lens material), add its keys to +`getLooksControlNames()` in `indra/newview/llpresetsmanager.cpp` — the single +source of truth for save, dirty-watching, and the whitelist-filtered apply. +Skip it and Looks silently won't carry the effect. Do **not** add: Scene-tab +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. + +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 +will leave the new effect at whatever the user had rather than clearing it — +`loadLooksPreset` writes only keys present in *both* the whitelist and the file, +so a missing key is a silent no-op and "Neutral" stops meaning neutral. To +refresh them after tuning: save the Look in the viewer, then copy the saved file +from `/presets/looks/` over the bundled one. + +A Look whose settings have since changed is shown as `Name *` in the combo, via +the `look_name_modified` string — **on the name, not beside it**. A detached +marker can only say that something somewhere has changed; an attached one says +which Look it was. It is display-only (`setLabel`, and nothing is selected while +modified), and `onLookSelected` reads the chosen item's own text, so the marker +cannot travel into a Look name. + +Seeding is recorded per name in `looks_seeded.xml` (user settings root, *not* +the looks directory — anything `*.xml` in there is enumerated as a Look), so a +Look bundled in a later release still reaches an existing user while one they +deleted stays deleted. Nothing is ever copied over a file that already exists. + +### 10. Verify + +- XML well-formedness before launching (any XML-capable tool). +- Two-way binding: move the row, watch the key in Debug Settings; edit the key + there, watch the row follow. +- Gating flips live; section Reset All touches exactly the section's keys + (including its Advanced sibling); the tab opens to full height with nothing + clipped or drawing over the next section. +- If added to the whitelist: save a Look, change the setting (dirty `*` + appears), re-apply (value returns). +- For a wheel: drag the puck and watch the fields and Debug Settings follow; + type a value and watch the puck move to match. Drag hard into a corner and + confirm it rides the reachable boundary rather than freezing or jumping, and + that the number shown is the clamped one. +- For a graph: drag each handle to its limit and confirm the setting clamps and + the handle is put back where the setting actually landed. +- For anything measured (scopes, vectorscope): change the thing it measures and + confirm the readout moves the way the control says it should. +- For a section switch: **untick** it (ticked is on) and confirm the image + changes **and the Looks `*` does not appear**. Then close the Lightbox with it + still unticked and confirm the render comes back — state parked outside the + floater is the failure mode here, and it looks like a renderer bug rather than + a UI one. +- For the Sky tab's day cycle: freeze, wait past the point the sky would have + moved, and confirm it has not. Then untick and confirm you get back *what you + had*, not the region default — set a Personal Lighting sky first, since that + is the case a missing capture loses. Fly through an altitude band while frozen + and confirm the sky holds; check the water stopped too, not just the sky. + Finally, confirm the presets land somewhere plausible on a region whose day + cycle is not the default one, because a hardcoded fraction would also look + right on a default region. +- For anything on an accordion header: click it and confirm the section does + **not** expand or collapse, then hover it with the section expanded and + confirm its own tooltip appears rather than the title's. Those are the two + interceptions in §3b, and the tooltip one only shows up when expanded — test + it collapsed and it will look fine. +- If you added a print effect: take a snapshot with "No post-processing" ticked + and confirm the effect is absent from the saved file, not just from the + preview. +- **Developer-build staging trap: a build never stages XUI at all.** Not + "unreliably" — never. This is worth knowing rather than guessing at, because a + stale copy looks exactly like an edit that did not work. + + The `POST_BUILD` custom command on the viewer binary target does run + (`viewer_manifest.py --actions=copy`, `newview/CMakeLists.txt`), but the + manifest's entire `skins` / `app_settings` / `character` / `fonts` block sits + behind `if self.is_packaging_viewer():`, which is `'package' in actions` — and + the build passes `--actions=copy`. So the copy stage refreshes the exe, the + DLLs and the plugins, and nothing else. + + - Relinking does **not** help. Neither does changing C++ alongside the XML, + neither does re-running CMake, and it makes no difference whether the file + is new or existing. + - Copy changed files into `build-.../newview//skins/...` yourself, and + check the result by **hash**: the staged tree has files of many different + ages, so a timestamp tells you nothing. + - To find drift across the whole tree, walk `indra/newview/skins/**/*.xml` and + compare each against its counterpart under + `build-.../newview//skins/`. + + An earlier revision of this file blamed the relink. It was wrong, and it cost + a debugging round each of the three times it was believed. diff --git a/indra/llrender/llrender2dutils.cpp b/indra/llrender/llrender2dutils.cpp index 477ce853ce..61f1219cea 100644 --- a/indra/llrender/llrender2dutils.cpp +++ b/indra/llrender/llrender2dutils.cpp @@ -266,6 +266,161 @@ void gl_line_2d(S32 x1, S32 y1, S32 x2, S32 y2, const LLColor4 &color ) gGL.end(); } +void gl_polyline_2d(const std::vector& points, const LLColor4& color, F32 width, bool closed) +{ + const size_t count = points.size(); + if (count < 2) + { + return; + } + + const F32 half = llmax(width, 0.1f) * 0.5f; + // One pixel of falloff either side. Wider reads as a blurred line rather + // than a smooth one; narrower leaves the stair steps visible. + constexpr F32 FEATHER = 1.0f; + // A mitre grows as 1/cos(theta/2), so a hairpin would send the corner off + // the widget. Past this the join is simply blunted; on a curve sampled + // densely enough to be smooth the clamp never engages. + constexpr F32 MITRE_LIMIT = 4.0f; + + const size_t segments = closed ? count : count - 1; + + // Per-vertex mitre normals, so consecutive segments share their corner + // vertices exactly and the ribbon has no notches at the joins. + std::vector normals(count); + for (size_t i = 0; i < count; ++i) + { + const bool has_prev = closed || i > 0; + const bool has_next = closed || i + 1 < count; + + LLVector2 n_prev(0.f, 0.f); + LLVector2 n_next(0.f, 0.f); + + if (has_prev) + { + const LLVector2& p = points[(i + count - 1) % count]; + LLVector2 d = points[i] - p; + if (d.lengthSquared() > 0.f) + { + d.normalize(); + n_prev.set(-d.mV[VY], d.mV[VX]); + } + } + if (has_next) + { + LLVector2 d = points[(i + 1) % count] - points[i]; + if (d.lengthSquared() > 0.f) + { + d.normalize(); + n_next.set(-d.mV[VY], d.mV[VX]); + } + } + + LLVector2 n = n_prev + n_next; + if (n.lengthSquared() <= 0.f) + { + // An end point (one neighbour) or a doubled-back segment; fall + // back to whichever side actually has a direction. + n = has_next ? n_next : n_prev; + } + if (n.lengthSquared() <= 0.f) + { + normals[i].set(0.f, 0.f); + continue; + } + n.normalize(); + + // Lengthen the mitre so the ribbon keeps a constant apparent width + // through the turn rather than pinching. + const LLVector2& reference = (n_next.lengthSquared() > 0.f) ? n_next : n_prev; + const F32 cos_half = n * reference; + const F32 scale = (cos_half > 1.f / MITRE_LIMIT) ? (1.f / cos_half) : MITRE_LIMIT; + normals[i] = n * scale; + } + + gGL.getTextureSlot(0)->unbind(); + + const LLColor4 edge(color.mV[VRED], color.mV[VGREEN], color.mV[VBLUE], 0.f); + + // TRIANGLES rather than a strip: LLRender auto-flushes this mode on a + // multiple of three, so a long polyline cannot overrun the immediate-mode + // vertex buffer and lose its tail the way an unsplittable strip would. + gGL.begin(LLRender::TRIANGLES); + for (size_t s = 0; s < segments; ++s) + { + const size_t i0 = s; + const size_t i1 = (s + 1) % count; + const LLVector2& p0 = points[i0]; + const LLVector2& p1 = points[i1]; + if ((p1 - p0).lengthSquared() <= 0.f) + { + continue; + } + const LLVector2& n0 = normals[i0]; + const LLVector2& n1 = normals[i1]; + + // Three bands per segment: the opaque core, and a fading skirt on + // each side. The skirts are what actually anti-alias the edge. + const F32 offsets[4] = { -(half + FEATHER), -half, half, half + FEATHER }; + const LLColor4* colors[4] = { &edge, &color, &color, &edge }; + + for (S32 band = 0; band < 3; ++band) + { + const LLVector2 a0 = p0 + n0 * offsets[band]; + const LLVector2 a1 = p1 + n1 * offsets[band]; + const LLVector2 b0 = p0 + n0 * offsets[band + 1]; + const LLVector2 b1 = p1 + n1 * offsets[band + 1]; + const LLColor4& ca = *colors[band]; + const LLColor4& cb = *colors[band + 1]; + + gGL.color4fv(ca.mV); gGL.vertex2f(a0.mV[VX], a0.mV[VY]); + gGL.color4fv(ca.mV); gGL.vertex2f(a1.mV[VX], a1.mV[VY]); + gGL.color4fv(cb.mV); gGL.vertex2f(b1.mV[VX], b1.mV[VY]); + + gGL.color4fv(ca.mV); gGL.vertex2f(a0.mV[VX], a0.mV[VY]); + gGL.color4fv(cb.mV); gGL.vertex2f(b1.mV[VX], b1.mV[VY]); + gGL.color4fv(cb.mV); gGL.vertex2f(b0.mV[VX], b0.mV[VY]); + } + } + gGL.end(); + gGL.flush(); +} + +void gl_polyfill_2d(const std::vector& points, F32 baseline_y, const LLColor4& color) +{ + const size_t count = points.size(); + if (count < 2) + { + return; + } + + gGL.getTextureSlot(0)->unbind(); + + // TRIANGLES, not a strip, for the reason gl_polyline_2d gives: LLRender + // auto-flushes this mode on a multiple of three, so a densely sampled curve + // cannot overrun the immediate-mode buffer and lose its tail. + gGL.begin(LLRender::TRIANGLES); + gGL.color4fv(color.mV); + for (size_t i = 0; i + 1 < count; ++i) + { + const LLVector2& p0 = points[i]; + const LLVector2& p1 = points[i + 1]; + + // Degenerate where the curve touches the baseline, which is the common + // case for a weight band outside its range. Harmless, and cheaper to + // emit than to test for. + gGL.vertex2f(p0.mV[VX], baseline_y); + gGL.vertex2f(p1.mV[VX], baseline_y); + gGL.vertex2f(p1.mV[VX], p1.mV[VY]); + + gGL.vertex2f(p0.mV[VX], baseline_y); + gGL.vertex2f(p1.mV[VX], p1.mV[VY]); + gGL.vertex2f(p0.mV[VX], p0.mV[VY]); + } + gGL.end(); + gGL.flush(); +} + void gl_triangle_2d(S32 x1, S32 y1, S32 x2, S32 y2, S32 x3, S32 y3, const LLColor4& color, bool filled) { gGL.getTextureSlot(0)->unbind(); @@ -1066,6 +1221,46 @@ void gl_washer_segment_2d(F32 outer_radius, F32 inner_radius, F32 start_radians, gGL.end(); } +void gl_washer_angular_2d(F32 outer_radius, F32 inner_radius, + const std::vector& colors, F32 inner_fade) +{ + const size_t steps = colors.size(); + if (steps < 3) + { + return; + } + + gGL.getTextureSlot(0)->unbind(); + + // TRIANGLES rather than a strip, for the same reason gl_polyline_2d does: + // LLRender auto-flushes this mode on a multiple of three, so a finely + // stepped ring cannot overrun the immediate-mode buffer and lose its tail. + gGL.begin(LLRender::TRIANGLES); + for (size_t i = 0; i < steps; ++i) + { + const size_t j = (i + 1) % steps; + const F32 a0 = F_TWO_PI * (F32)i / (F32)steps; + const F32 a1 = F_TWO_PI * (F32)j / (F32)steps; + + const F32 c0 = cosf(a0), s0 = sinf(a0); + const F32 c1 = cosf(a1), s1 = sinf(a1); + + LLColor4 in0(colors[i]); in0.mV[VALPHA] *= inner_fade; + LLColor4 in1(colors[j]); in1.mV[VALPHA] *= inner_fade; + + // Outer i -> outer j -> inner j, then outer i -> inner j -> inner i. + gGL.color4fv(colors[i].mV); gGL.vertex2f(outer_radius * c0, outer_radius * s0); + gGL.color4fv(colors[j].mV); gGL.vertex2f(outer_radius * c1, outer_radius * s1); + gGL.color4fv(in1.mV); gGL.vertex2f(inner_radius * c1, inner_radius * s1); + + gGL.color4fv(colors[i].mV); gGL.vertex2f(outer_radius * c0, outer_radius * s0); + gGL.color4fv(in1.mV); gGL.vertex2f(inner_radius * c1, inner_radius * s1); + gGL.color4fv(in0.mV); gGL.vertex2f(inner_radius * c0, inner_radius * s0); + } + gGL.end(); + gGL.flush(); +} + void gl_rect_2d_simple_tex( S32 width, S32 height ) { gGL.begin( LLRender::TRIANGLES ); diff --git a/indra/llrender/llrender2dutils.h b/indra/llrender/llrender2dutils.h index 7e6b124446..8995ed6234 100644 --- a/indra/llrender/llrender2dutils.h +++ b/indra/llrender/llrender2dutils.h @@ -34,10 +34,12 @@ #include "llrect.h" #include "llsingleton.h" #include "llglslshader.h" +#include "v2math.h" + +#include class LLColor4; class LLVector3; -class LLVector2; class LLUIImage; class LLUUID; @@ -48,6 +50,34 @@ void gl_state_for_2d(S32 width, S32 height); void gl_line_2d(S32 x1, S32 y1, S32 x2, S32 y2); void gl_line_2d(S32 x1, S32 y1, S32 x2, S32 y2, const LLColor4 &color ); + +// Anti-aliased polyline through `points`, drawn as a ribbon of triangles with +// a one-pixel alpha falloff along each edge. +// +// GL_LINE_SMOOTH is not used, and deliberately: it appears nowhere in this +// tree, core profiles routinely ignore it, and LLRender::setLineWidth already +// clamps to mAliasedLineRange -- which is [1,1] on most core drivers -- so +// neither smoothing nor width can be relied on from the fixed pipeline. Doing +// the coverage by hand costs a few triangles per segment and looks the same +// everywhere. +// +// Joins are mitred, so a continuous curve has no notches at its vertices. The +// mitre is clamped, so a hairpin turn is blunted rather than shooting off to +// infinity. Fewer than two points draws nothing; coincident points are skipped. +void gl_polyline_2d(const std::vector& points, const LLColor4& color, + F32 width = 1.f, bool closed = false); + +// The area between `points` and the horizontal line y = `baseline_y`, flat +// filled. The companion to gl_polyline_2d for a graph whose meaning is how much +// is under the curve rather than where the curve runs -- a weight band, an +// occupancy plot -- where an outline alone reads as three crossing lines. +// +// The edge is left aliased: pass a translucent colour, as such a fill wants, +// and the polyline's feathered skirt would double up against the fill it sits +// on and draw a darker seam along the top. Outline it with gl_polyline_2d if a +// crisp edge is wanted; then the two feathers sit on the same path. +void gl_polyfill_2d(const std::vector& points, F32 baseline_y, + const LLColor4& color); void gl_triangle_2d(S32 x1, S32 y1, S32 x2, S32 y2, S32 x3, S32 y3, const LLColor4& color, bool filled); void gl_rect_2d_simple( S32 width, S32 height ); @@ -71,6 +101,21 @@ void gl_corners_2d(S32 left, S32 top, S32 right, S32 bottom, S32 length, F32 max void gl_washer_2d(F32 outer_radius, F32 inner_radius, S32 steps, const LLColor4& inner_color, const LLColor4& outer_color); void gl_washer_segment_2d(F32 outer_radius, F32 inner_radius, F32 start_radians, F32 end_radians, S32 steps, const LLColor4& inner_color, const LLColor4& outer_color); +// A washer whose colour varies AROUND the sweep rather than across it: one +// entry in `colors` per step, wrapping back to the first. The washers above +// take a single inner and outer colour held constant along the arc, which can +// only ever make a radial gradient -- this is the angular one, and it is what +// a hue ring needs. +// +// `inner_fade` scales each colour's alpha at the inner edge, so a ring can +// fall off toward its centre instead of ending in a hard step. +// +// Like its siblings and unlike gl_circle_2d, this draws around the CURRENT +// origin and does not push the UI matrix -- wrap it in gGL.pushUIMatrix() and +// translateUI() yourself. Fewer than three colours draws nothing. +void gl_washer_angular_2d(F32 outer_radius, F32 inner_radius, + const std::vector& colors, F32 inner_fade = 1.f); + void gl_draw_image(S32 x, S32 y, LLTexture* image, const LLColor4& color = UI_VERTEX_COLOR, const LLRectf& uv_rect = LLRectf(0.f, 1.f, 1.f, 0.f)); void gl_draw_scaled_target(S32 x, S32 y, S32 width, S32 height, LLRenderTarget* target, const LLColor4& color = UI_VERTEX_COLOR, const LLRectf& uv_rect = LLRectf(0.f, 1.f, 1.f, 0.f)); void gl_draw_scaled_image(S32 x, S32 y, S32 width, S32 height, LLTexture* image, const LLColor4& color = UI_VERTEX_COLOR, const LLRectf& uv_rect = LLRectf(0.f, 1.f, 1.f, 0.f)); diff --git a/indra/llrender/llshadermgr.cpp b/indra/llrender/llshadermgr.cpp index 823838b32f..d9e365413d 100644 --- a/indra/llrender/llshadermgr.cpp +++ b/indra/llrender/llshadermgr.cpp @@ -1936,6 +1936,11 @@ void LLShaderMgr::initAttribsAndUniforms() // Previews mReservedUniforms.push_back("uPreviewMode"); + // Reference still + mReservedUniforms.push_back("uReferenceStill"); + mReservedUniforms.push_back("uRefWipeMode"); + mReservedUniforms.push_back("uRefWipePos"); + // Text Shadow mReservedUniforms.push_back("textShadowMode"); @@ -1961,6 +1966,18 @@ void LLShaderMgr::initAttribsAndUniforms() LL_ERRS() << "Duplicate reserved uniform name found: " << mReservedUniforms[i] << LL_ENDL; } dupe_check.insert(mReservedUniforms[i]); + + // An array uniform belongs here under its bare name. LLGLSLShader::mapUniform + // chops the "[0]" off whatever GL reports before matching against this table, + // so a subscript here can never match anything -- and nothing complains. The + // location is never recorded, every upload to it silently does nothing, and the + // shader reads the array as all zeroes, which surfaces as a rendering fault a + // long way from the cause. Fatal for the same reason as the two checks above. + if (mReservedUniforms[i].find('[') != std::string::npos) + { + LL_ERRS() << "Reserved uniform '" << mReservedUniforms[i] << "' carries a subscript; " + << "array uniforms are declared here by their bare name" << LL_ENDL; + } } } diff --git a/indra/llrender/llshadermgr.h b/indra/llrender/llshadermgr.h index a058c75d94..1d2aa00401 100644 --- a/indra/llrender/llshadermgr.h +++ b/indra/llrender/llshadermgr.h @@ -514,6 +514,11 @@ class LLShaderMgr // Previews PREVIEW_MODE, // "uPreviewMode" + // Reference still — grab a frame, wipe the live image against it + REFERENCE_STILL, // "uReferenceStill" + REFERENCE_WIPE_MODE, // "uRefWipeMode" + REFERENCE_WIPE_POS, // "uRefWipePos" + // End Alchemy Effects Stack TEXT_SHADOW_MODE, // "textShadowMode" diff --git a/indra/llui/llaccordionctrltab.cpp b/indra/llui/llaccordionctrltab.cpp index b642566f6c..1a98cf4294 100644 --- a/indra/llui/llaccordionctrltab.cpp +++ b/indra/llui/llaccordionctrltab.cpp @@ -46,6 +46,19 @@ static const F32 AUTO_OPEN_TIME = 1.f; static const S32 VERTICAL_MULTIPLE = 16; static const S32 PARENT_BORDER_MARGIN = 5; +// Optional header checkbox. It sits at the right end because the left is +// already spoken for by the expand arrow and the title, and because the right +// end is the one place a column of them lines up down an accordion. +static const S32 HEADER_CHECKBOX_RIGHT_PAD = 6; +// Between the checkbox and the title, which ellipses rather than run under it. +static const S32 HEADER_CHECKBOX_TEXT_GAP = 6; +// Default size, used when the XUI gives no width or height. Wide enough for +// the 13px box and the slack LLCheckBoxCtrl puts around it, and no wider, +// since a bare checkbox is what a header wants; anything with a label has to +// state its own width. +static const S32 HEADER_CHECKBOX_WIDTH = 24; +static const S32 HEADER_CHECKBOX_HEIGHT = 16; + static LLDefaultChildRegistry::Register t1("accordion_tab"); class LLAccordionCtrlTab::LLAccordionCtrlTabHeader : public LLUICtrl @@ -77,6 +90,12 @@ class LLAccordionCtrlTab::LLAccordionCtrlTabHeader : public LLUICtrl void setSelected(bool is_selected) { mIsSelected = is_selected; } + // Adopt the checkbox the tab built. Kept null otherwise, and every use of + // it in this file is guarded, so a header without one behaves exactly as + // it did before there was such a thing. + void setHeaderCheckBox(LLCheckBoxCtrl* checkbox); + LLCheckBoxCtrl* getHeaderCheckBox() const { return mHeaderCheckBox; } + virtual void onMouseEnter(S32 x, S32 y, MASK mask); virtual void onMouseLeave(S32 x, S32 y, MASK mask); virtual bool handleKey(KEY key, MASK mask, bool called_from_parent); @@ -88,6 +107,7 @@ class LLAccordionCtrlTab::LLAccordionCtrlTabHeader : public LLUICtrl private: LLTextBox* mHeaderTextbox; + LLCheckBoxCtrl* mHeaderCheckBox = nullptr; // Overlay images (arrows) LLPointer mImageCollapsed; @@ -196,6 +216,16 @@ void LLAccordionCtrlTab::LLAccordionCtrlTabHeader::setTitleColor(LLUIColor color } } +void LLAccordionCtrlTab::LLAccordionCtrlTabHeader::setHeaderCheckBox(LLCheckBoxCtrl* checkbox) +{ + mHeaderCheckBox = checkbox; + addChild(checkbox); + + // Where it goes is decided in reshape, which has the header's size; this + // just makes sure it is somewhere reasonable if reshape has already run. + reshape(getRect().getWidth(), getRect().getHeight()); +} + void LLAccordionCtrlTab::LLAccordionCtrlTabHeader::draw() { S32 width = getRect().getWidth(); @@ -247,10 +277,39 @@ void LLAccordionCtrlTab::LLAccordionCtrlTabHeader::draw() void LLAccordionCtrlTab::LLAccordionCtrlTabHeader::reshape(S32 width, S32 height, bool called_from_parent /* = true */) { + // This override never chains to LLUICtrl::reshape, so follows flags do + // nothing to header children and everything in here is positioned by + // hand. The checkbox is placed first because the title has to stop short + // of it; without one, text_right stays the full width and the rest of + // this function is what it always was. + S32 text_right = width; + if (mHeaderCheckBox) + { + const LLRect& old_check_rect = mHeaderCheckBox->getRect(); + LLRect check_rect; + check_rect.setLeftTopAndSize(width - HEADER_CHECKBOX_RIGHT_PAD - old_check_rect.getWidth(), + (height + old_check_rect.getHeight()) / 2, + old_check_rect.getWidth(), + old_check_rect.getHeight()); + if (check_rect != old_check_rect) + { + // setRect rather than reshape: the size is not changing, only the + // position, and LLCheckBoxCtrl::reshape does nothing when the + // size is unchanged. The bounding rect is what the mouse is + // tested against, so it has to be told the control moved. + mHeaderCheckBox->setRect(check_rect); + mHeaderCheckBox->updateBoundingRect(); + } + + // A header narrow enough for these to cross would otherwise hand the + // textbox an inside-out rect. + text_right = llmax(check_rect.mLeft - HEADER_CHECKBOX_TEXT_GAP, HEADER_TEXT_LEFT_OFFSET); + } + S32 header_height = mHeaderTextbox->getTextPixelHeight(); LLRect old_header_rect = mHeaderTextbox->getRect(); - LLRect textboxRect(HEADER_TEXT_LEFT_OFFSET, (height + header_height) / 2, width, (height - header_height) / 2); + LLRect textboxRect(HEADER_TEXT_LEFT_OFFSET, (height + header_height) / 2, text_right, (height - header_height) / 2); if (old_header_rect.getHeight() != textboxRect.getHeight() || old_header_rect.mLeft != textboxRect.mLeft || old_header_rect.mTop != textboxRect.mTop @@ -351,6 +410,7 @@ LLAccordionCtrlTab::Params::Params() ,header_image_pressed("header_image_pressed") ,header_image_focused("header_image_focused") ,header_text_color("header_text_color") + ,header_check_box("header_check_box") ,fit_panel("fit_panel",true) ,selection_enabled("selection_enabled", false) { @@ -373,6 +433,7 @@ LLAccordionCtrlTab::LLAccordionCtrlTab(const LLAccordionCtrlTab::Params&p) ,mSelectionEnabled(p.selection_enabled) ,mContainerPanel(NULL) ,mScrollbar(NULL) + ,mHeaderCheckBox(NULL) { mStoredOpenCloseState = false; mWasStateStored = false; @@ -385,6 +446,36 @@ LLAccordionCtrlTab::LLAccordionCtrlTab(const LLAccordionCtrlTab::Params&p) mHeader = LLUICtrlFactory::create(headerParams); addChild(mHeader, 1); + if (p.header_check_box.isProvided()) + { + LLCheckBoxCtrl::Params checkParams = p.header_check_box; + + // The header decides where this goes on every reshape, so keep + // follows out of it: an anchored child would be dragged somewhere + // else first and land back here, which is at best wasted work. + checkParams.follows.flags(FOLLOWS_NONE); + + // A checkbox is normally sized by the XUI that places it, and this + // one is placed by us. Default it to the bare box; anything with a + // label is the caller's to size, and saying so is the whole reason + // this reads the params rather than always overriding them. + if (!checkParams.rect.width.isProvided()) + { + checkParams.rect.width = HEADER_CHECKBOX_WIDTH; + } + if (!checkParams.rect.height.isProvided()) + { + checkParams.rect.height = HEADER_CHECKBOX_HEIGHT; + } + + // Built here rather than in the header because create() resolves + // control_name and commit_callback against the registrar scope that + // is open now -- the floater being read from XUI -- and the header + // is only where it ends up living. + mHeaderCheckBox = LLUICtrlFactory::create(checkParams); + mHeader->setHeaderCheckBox(mHeaderCheckBox); + } + LLFocusableElement::setFocusReceivedCallback(boost::bind(&LLAccordionCtrlTab::selectOnFocusReceived, this)); if (!p.selection_enabled) @@ -524,8 +615,40 @@ void LLAccordionCtrlTab::onUpdateScrollToChild(const LLUICtrl *cntrl) LLUICtrl::onUpdateScrollToChild(cntrl); } +// Where the header checkbox is, in this tab's own coordinates, so the two +// header-band branches below can ask whether a click or a hover is really +// meant for it. Returns false when there is no checkbox, which is the answer +// for every tab that did not ask for one. +bool LLAccordionCtrlTab::pointInHeaderCheckBox(S32 x, S32 y) const +{ + if (!mHeaderCheckBox || !mHeaderCheckBox->getVisible()) + { + return false; + } + + LLRect check_rect; + mHeaderCheckBox->localRectToOtherView(mHeaderCheckBox->getLocalRect(), &check_rect, this); + return check_rect.pointInRect(x, y); +} + bool LLAccordionCtrlTab::handleMouseDown(S32 x, S32 y, MASK mask) { + // The branch below claims the whole header band and opens or closes the + // tab, so a checkbox living in that band would never be clicked. Send + // this one press down the normal child path instead, which reaches the + // header and then the checkbox inside it. + // + // Only when it is taken, though: LLCheckBoxCtrl hit-tests against its + // bounding rect, which is the box and label rather than the whole + // control, so a press can land inside the rect and still be refused. Left + // at "return", that inset would be a ring of pixels around the checkbox + // where clicking did nothing at all; falling through means it does what + // the rest of the header does. + if (pointInHeaderCheckBox(x, y) && LLUICtrl::handleMouseDown(x, y, mask)) + { + return true; + } + if (mCollapsible && mHeaderVisible && mCanOpenClose) { if (y >= (getRect().getHeight() - HEADER_HEIGHT)) @@ -1139,6 +1262,21 @@ void LLAccordionCtrlTab::ctrlSetLeftTopAndSize(LLView* panel, S32 left, S32 top, bool LLAccordionCtrlTab::handleToolTip(S32 x, S32 y, MASK mask) { + // Same reason as handleMouseDown: the header branch answers for the whole + // band, and it answers with the title's tooltip. A header checkbox is a + // separate control saying a separate thing, so let it speak for its own + // rect. Its coordinates have to be converted -- the header branch below + // passes this tab's, which is why its own children never match. + if (pointInHeaderCheckBox(x, y)) + { + LLRect check_rect; + mHeaderCheckBox->localRectToOtherView(mHeaderCheckBox->getLocalRect(), &check_rect, this); + if (mHeaderCheckBox->handleToolTip(x - check_rect.mLeft, y - check_rect.mBottom, mask)) + { + return true; + } + } + //header may be not the first child but we need to process it first if (y >= (getRect().getHeight() - HEADER_HEIGHT - HEADER_HEIGHT / 2)) { diff --git a/indra/llui/llaccordionctrltab.h b/indra/llui/llaccordionctrltab.h index 419a995e7f..edc22b4607 100644 --- a/indra/llui/llaccordionctrltab.h +++ b/indra/llui/llaccordionctrltab.h @@ -29,6 +29,7 @@ #include #include "llrect.h" +#include "llcheckboxctrl.h" #include "lluictrl.h" #include "lluicolor.h" #include "llstyle.h" @@ -81,6 +82,19 @@ class LLAccordionCtrlTab : public LLUICtrl Optional header_visible; + // An interactive checkbox at the right end of the header, for a + // section that can be switched off without being collapsed. Omitted + // by every tab that does not want one, and there is no checkbox at + // all in that case -- not a hidden one -- so the tabs already in the + // viewer keep the header they have. + // + // A full check_box params block, so control_name, enabled_control, + // tool_tip and commit_callback all work as they do anywhere else. + // Sizing is the one thing it does not take from the block: an + // unsized checkbox gets a default square wide enough for the box, + // and a header checkbox that carries a label has to say how wide. + Optional header_check_box; + Optional fit_panel; Optional selection_enabled; @@ -134,6 +148,12 @@ class LLAccordionCtrlTab : public LLUICtrl void canOpenClose(bool can_open_close) { mCanOpenClose = can_open_close; }; bool canOpenClose() const { return mCanOpenClose; }; + // The header checkbox, or null for a tab that did not ask for one -- + // which is all of them unless header_check_box was given. Callers that + // want the value should reach it through this rather than getChild: the + // checkbox lives inside the header, not in the tab's own child list. + LLCheckBoxCtrl* getHeaderCheckBox() const { return mHeaderCheckBox; } + virtual bool postBuild(); S32 notifyParent(const LLSD& info); @@ -219,11 +239,19 @@ class LLAccordionCtrlTab : public LLUICtrl void selectOnFocusReceived(); void deselectOnFocusLost(); + // Whether (x, y), in this tab's coordinates, lands on the header + // checkbox. False whenever there is no checkbox. + bool pointInHeaderCheckBox(S32 x, S32 y) const; + private: class LLAccordionCtrlTabHeader; LLAccordionCtrlTabHeader* mHeader; //Header + // Owned by the header, which is where it is drawn and where its rect is + // kept; held here too because the tab is what the mouse reaches first. + LLCheckBoxCtrl* mHeaderCheckBox; + bool mDisplayChildren; //Expanded/collapsed bool mCollapsible; bool mHeaderVisible; diff --git a/indra/newview/CMakeLists.txt b/indra/newview/CMakeLists.txt index 4e5454c2dc..012f2f63d9 100644 --- a/indra/newview/CMakeLists.txt +++ b/indra/newview/CMakeLists.txt @@ -157,6 +157,11 @@ set(viewer_SOURCE_FILES alchatcommand.cpp alcheatcodes.cpp alclassifieditem.cpp + alcolorwheelctrl.cpp + alcolorwheelmodel.cpp + alcurveeditorctrl.cpp + alcurvemodel.cpp + aldaycyclelandmarks.cpp alderenderlist.cpp aldroptarget.cpp alfloaterblocked.cpp @@ -172,9 +177,12 @@ set(viewer_SOURCE_FILES alfloaterregiontracker.cpp alfloatersceneexplorer.cpp alfloatersceneexplorerfilters.cpp + alfloaterscopes.cpp alfloatertransactionlog.cpp alsceneexplorerpredicate.cpp + alscopedata.cpp alfloaterwebprofile.cpp + algradehistory.cpp allegacynotificationwellwindow.cpp alobjectproperties.cpp alpanelaomini.cpp @@ -202,8 +210,10 @@ set(viewer_SOURCE_FILES alsceneexplorermodel.cpp alstreaminfo.cpp altoolalign.cpp + altoolscenepicker.cpp alunzip.cpp alviewermenu.cpp + alwhitebalancesolver.cpp fsfloaterposer.cpp fsjointpose.cpp fsmaniprotatejoint.cpp @@ -926,6 +936,11 @@ set(viewer_HEADER_FILES alchatbar.h alchatcommand.h alclassifieditem.h + alcolorwheelctrl.h + alcolorwheelmodel.h + alcurveeditorctrl.h + alcurvemodel.h + aldaycyclelandmarks.h alderenderlist.h aldroptarget.h alfloaterblocked.h @@ -941,9 +956,12 @@ set(viewer_HEADER_FILES alfloaterregiontracker.h alfloatersceneexplorer.h alfloatersceneexplorerfilters.h + alfloaterscopes.h alfloatertransactionlog.h alsceneexplorerpredicate.h + alscopedata.h alfloaterwebprofile.h + algradehistory.h allegacynotificationwellwindow.h alobjectproperties.h alpanelaomini.h @@ -971,8 +989,10 @@ set(viewer_HEADER_FILES alsceneexplorermodel.h alstreaminfo.h altoolalign.h + altoolscenepicker.h alunzip.h alviewermenu.h + alwhitebalancesolver.h fsfloaterposer.h fsjointpose.h fsmaniprotatejoint.h @@ -2373,7 +2393,13 @@ if (BUILD_TESTING) # This creates a separate test project per file listed. SET(viewer_TEST_SOURCE_FILES + alcolorwheelmodel.cpp + alcurvemodel.cpp + aldaycyclelandmarks.cpp + algradehistory.cpp alsceneexplorerpredicate.cpp + alscopedata.cpp + alwhitebalancesolver.cpp llagentaccess.cpp lldateutil.cpp # llmediadataclient.cpp @@ -2384,6 +2410,7 @@ if (BUILD_TESTING) # llvocache.cpp llworldmap.cpp llworldmipmap.cpp + lutcube.cpp ) set_source_files_properties( @@ -2395,6 +2422,16 @@ if (BUILD_TESTING) #llviewertexturelist.cpp ) + # The vectorscope bins on the colour wheels' own chroma plane, so the scope + # data test links the model that defines it rather than keeping a second copy + # of the basis that could drift from it. + set_source_files_properties( + alscopedata.cpp + PROPERTIES + LL_TEST_ADDITIONAL_SOURCE_FILES + alcolorwheelmodel.cpp + ) + # set_source_files_properties( # llvocache.cpp # PROPERTIES diff --git a/indra/newview/alcolorwheelctrl.cpp b/indra/newview/alcolorwheelctrl.cpp new file mode 100644 index 0000000000..184c327bb1 --- /dev/null +++ b/indra/newview/alcolorwheelctrl.cpp @@ -0,0 +1,574 @@ +/** + * @file alcolorwheelctrl.cpp + * @brief Colourist's wheel: hue ring, draggable puck, master slider, fields + * + * $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$ + */ + +#include "llviewerprecompiledheaders.h" + +#include "alcolorwheelctrl.h" + +#include "llbutton.h" +#include "llfocusmgr.h" +#include "lllineeditor.h" +#include "lllocalcliprect.h" +#include "llrender.h" +#include "llrender2dutils.h" +#include "llsliderctrl.h" +#include "lltextbox.h" +#include "lltextvalidate.h" +#include "lluicolortable.h" +#include "llwindow.h" + +#include + +static LLDefaultChildRegistry::Register r("color_wheel"); + +namespace +{ +constexpr S32 LABEL_HEIGHT = 15; +constexpr S32 ROW_HEIGHT = 16; +constexpr S32 CAPTION_HEIGHT = 11; +constexpr S32 GAP = 3; +constexpr S32 RESET_SIZE = 15; +/// Extra pixels around the puck that still count as grabbing it. +constexpr S32 GRAB_SLOP = 4; + +/// Move a child and let it re-derive whatever it computes from its own size. +/// +/// setRect() alone is not enough: it writes the rectangle and nothing else, so +/// anything a widget works out in reshape() keeps its old answer. +/// LLLineEditor is the one that bites here -- updateTextPadding() derives the +/// drawable text span from getRect().getWidth() and runs only from reshape(), +/// so an editor built at a placeholder width and then setRect() into place +/// draws its background across the new rect and its text into the old +/// one-pixel window. It reads as an editor that accepts typing and displays +/// nothing. +/// +/// called_from_parent must be true, and it is: we are the parent, laying the +/// child out. Pass false and LLView::reshape tells mParentView to reshape in +/// turn (llview.cpp:1498) -- straight back into our own reshape, updateLayout +/// and this function. That notify sits outside the zero-delta guard, so the +/// recursion does not even need a size change to run away; it overflows the +/// stack the moment the widget is first laid out. +void placeChild(LLView* child, const LLRect& rect) +{ + if (!child) + { + return; + } + child->setRect(rect); + child->reshape(rect.getWidth(), rect.getHeight(), true); +} +} + +ALColorWheelCtrl::Params::Params() +: label("label"), + centre("centre", 0.f), + min_value("min_value", -0.5f), + max_value("max_value", 0.5f), + lock_master("lock_master", false), + ring_thickness("ring_thickness", 10), + ring_steps("ring_steps", 72), + puck_radius("puck_radius", 4), + decimal_digits("decimal_digits", 2), + border_color("border_color", LLUIColorTable::instance().getColor("DefaultShadowLight")), + face_color("face_color", LLUIColorTable::instance().getColor("MenuDefaultBgColor")), + crosshair_color("crosshair_color", LLUIColorTable::instance().getColor("DefaultShadowDark")) +{ + changeDefault(mouse_opaque, true); +} + +ALColorWheelCtrl::ALColorWheelCtrl(const ALColorWheelCtrl::Params& p) +: LLUICtrl(p), + mBorderColor(p.border_color), + mFaceColor(p.face_color), + mCrosshairColor(p.crosshair_color), + mRingThickness(llmax(3, p.ring_thickness())), + mRingSteps(llclamp(p.ring_steps(), 12, 360)), + mPuckRadius(llmax(2, p.puck_radius())), + mDecimalDigits(llclamp(p.decimal_digits(), 0, 4)) +{ + mModel.configure(p.centre, p.min_value, p.max_value, p.lock_master); + mModel.reset(); + + // Ring colours depend only on the angle, so they are built once here + // rather than per frame. ALColorWheelModel::ringColor is the same function + // the puck goes through, which is what stops the ring and the puck + // disagreeing about where a hue is. + mRingColors.reserve(mRingSteps); + for (S32 i = 0; i < mRingSteps; ++i) + { + const LLVector3 c = ALColorWheelModel::ringColor(F_TWO_PI * (F32)i / (F32)mRingSteps); + mRingColors.emplace_back(c.mV[VX], c.mV[VY], c.mV[VZ], 1.f); + } + + // Every child below seeds its Params from getDefaultParams() first. + // LLUICtrlFactory::defaultBuilder does exactly that before it reads XUI, + // so the skin's widgets/*.xml supplies fonts, text colours, borders and + // background images. A bare Params misses all of it -- which renders as a + // line editor that accepts typing and displays nothing, because it has no + // text colour rather than no text. + if (!p.label().empty()) + { + LLTextBox::Params tp(LLUICtrlFactory::getDefaultParams()); + tp.name("wheel_label"); + tp.initial_value(p.label()); + tp.rect(LLRect(0, LABEL_HEIGHT, 1, 0)); + mLabel = LLUICtrlFactory::create(tp); + addChild(mLabel); + } + + { + LLButton::Params bp(LLUICtrlFactory::getDefaultParams()); + bp.name("wheel_reset"); + bp.rect(LLRect(0, RESET_SIZE, RESET_SIZE, 0)); + bp.scale_image(true); + bp.tool_tip("Reset to default"); + bp.click_callback.function([this](LLUICtrl*, const LLSD&) { onReset(); }); + mReset = LLUICtrlFactory::create(bp); + // Set after construction: the Params overlay slot wants an LLUIImage*, + // whereas this resolves the skin's name for us. + mReset->setImageOverlay("Refresh_Off", LLFontGL::HCENTER); + addChild(mReset); + } + + if (!mModel.isMasterLocked()) + { + LLSliderCtrl::Params sp(LLUICtrlFactory::getDefaultParams()); + sp.name("wheel_master"); + sp.rect(LLRect(0, ROW_HEIGHT, 1, 0)); + sp.min_value(mModel.getMin()); + sp.max_value(mModel.getMax()); + sp.increment(0.01f); + sp.decimal_digits(mDecimalDigits); + sp.show_text(true); + sp.can_edit_text(true); + sp.label_width(0); + sp.text_width(40); + sp.tool_tip("Master: moves all three channels together"); + sp.commit_callback.function([this](LLUICtrl*, const LLSD&) { onMasterCommit(); }); + mMaster = LLUICtrlFactory::create(sp); + addChild(mMaster); + } + + static const char* const CHANNEL_NAME[3] = { "wheel_r", "wheel_g", "wheel_b" }; + static const char* const CHANNEL_LABEL[3] = { "R", "G", "B" }; + static const char* const CHANNEL_TIP[3] = { "Red channel", "Green channel", "Blue channel" }; + + for (S32 i = 0; i < 3; ++i) + { + LLLineEditor::Params lp(LLUICtrlFactory::getDefaultParams()); + lp.name(CHANNEL_NAME[i]); + lp.rect(LLRect(0, ROW_HEIGHT, 1, 0)); + lp.max_length.chars(8); + lp.prevalidator(&LLTextValidate::validateFloat); + lp.commit_on_focus_lost(true); + lp.tool_tip(CHANNEL_TIP[i]); + lp.commit_callback.function([this, i](LLUICtrl*, const LLSD&) { onChannelCommit(i); }); + mFields[i] = LLUICtrlFactory::create(lp); + addChild(mFields[i]); + + // One caption per field rather than a single centred row: LLTextBase + // has no horizontal alignment param, so alignment here is a matter of + // where the rect is put, and a letter per column is clearer anyway. + LLTextBox::Params cp(LLUICtrlFactory::getDefaultParams()); + cp.name(std::string("wheel_caption_") + CHANNEL_LABEL[i]); + cp.initial_value(std::string(CHANNEL_LABEL[i])); + cp.font(LLFontGL::getFontSansSerifSmall()); + cp.rect(LLRect(0, CAPTION_HEIGHT, 1, 0)); + mCaptions[i] = LLUICtrlFactory::create(cp); + addChild(mCaptions[i]); + } + + updateLayout(); + syncChildren(); +} + +void ALColorWheelCtrl::reshape(S32 width, S32 height, bool called_from_parent) +{ + LLUICtrl::reshape(width, height, called_from_parent); + updateLayout(); +} + +void ALColorWheelCtrl::setEnabled(bool enabled) +{ + LLUICtrl::setEnabled(enabled); + if (mMaster) + { + mMaster->setEnabled(enabled); + } + if (mReset) + { + mReset->setEnabled(enabled); + } + for (LLLineEditor* field : mFields) + { + if (field) + { + field->setEnabled(enabled); + } + } + // The captions too, or a greyed-out wheel keeps three bright R G B labels + // under it and reads as half disabled. + for (LLTextBox* caption : mCaptions) + { + if (caption) + { + caption->setEnabled(enabled); + } + } + if (mLabel) + { + mLabel->setEnabled(enabled); + } +} + +void ALColorWheelCtrl::updateLayout() +{ + const LLRect r = getLocalRect(); + if (r.getWidth() <= 0 || r.getHeight() <= 0) + { + return; + } + + S32 top = r.mTop; + placeChild(mLabel, LLRect(r.mLeft, top, r.mRight - RESET_SIZE - 2, top - LABEL_HEIGHT)); + placeChild(mReset, LLRect(r.mRight - RESET_SIZE, top, r.mRight, top - RESET_SIZE)); + top -= llmax(LABEL_HEIGHT, RESET_SIZE) + GAP; + + // Fixed rows are measured up from the bottom; whatever is left in the + // middle becomes the ring, so a shorter widget shrinks the wheel instead + // of clipping its controls. + S32 bottom = r.mBottom; + const S32 field_gap = 2; + const S32 field_width = (r.getWidth() - field_gap * 2) / 3; + + for (S32 i = 0; i < 3; ++i) + { + const S32 left = r.mLeft + i * (field_width + field_gap); + placeChild(mCaptions[i], LLRect(left, bottom + CAPTION_HEIGHT, left + field_width, bottom)); + } + bottom += CAPTION_HEIGHT + 1; + + for (S32 i = 0; i < 3; ++i) + { + const S32 left = r.mLeft + i * (field_width + field_gap); + placeChild(mFields[i], LLRect(left, bottom + ROW_HEIGHT, left + field_width, bottom)); + } + bottom += ROW_HEIGHT + GAP; + + if (mMaster) + { + placeChild(mMaster, LLRect(r.mLeft, bottom + ROW_HEIGHT, r.mRight, bottom)); + bottom += ROW_HEIGHT + GAP; + } + + // Square, centred in what remains. + const S32 avail_h = llmax(0, top - bottom); + const S32 side = llmin(r.getWidth(), avail_h); + const S32 left = r.mLeft + (r.getWidth() - side) / 2; + const S32 wheel_bottom = bottom + (avail_h - side) / 2; + mWheelRect = LLRect(left, wheel_bottom + side, left + side, wheel_bottom); +} + +void ALColorWheelCtrl::wheelGeometry(F32& cx, F32& cy, F32& radius) const +{ + cx = (F32)mWheelRect.mLeft + (F32)mWheelRect.getWidth() * 0.5f; + cy = (F32)mWheelRect.mBottom + (F32)mWheelRect.getHeight() * 0.5f; + // The puck rides the inner edge of the ring, so the draggable radius stops + // where the colour band begins. + radius = llmax(1.f, (F32)llmin(mWheelRect.getWidth(), mWheelRect.getHeight()) * 0.5f + - (F32)mRingThickness); +} + +bool ALColorWheelCtrl::pointToPolar(S32 x, S32 y, F32& hue_out, F32& sat_out) const +{ + F32 cx, cy, radius; + wheelGeometry(cx, cy, radius); + + const F32 dx = (F32)x - cx; + const F32 dy = (F32)y - cy; + const F32 dist = sqrtf(dx * dx + dy * dy); + + if (dist < 1e-3f) + { + // Dead centre has no direction. Reporting atan2(0,0) here is what + // makes a puck dragged through the middle flick to red on the way out. + hue_out = mLastHue; + sat_out = 0.f; + return true; + } + + hue_out = atan2f(dy, dx); + if (hue_out < 0.f) + { + hue_out += F_TWO_PI; + } + // Clamp to the rim rather than refusing to move: pushing past maximum is a + // normal thing to do with a grading wheel. + sat_out = llmin(dist / radius, 1.f) * mModel.getMaxSat(); + return true; +} + +bool ALColorWheelCtrl::handleMouseDown(S32 x, S32 y, MASK mask) +{ + if (!getEnabled()) + { + return LLUICtrl::handleMouseDown(x, y, mask); + } + + F32 cx, cy, radius; + wheelGeometry(cx, cy, radius); + + const F32 dx = (F32)x - cx; + const F32 dy = (F32)y - cy; + if (sqrtf(dx * dx + dy * dy) > radius + (F32)(mPuckRadius + GRAB_SLOP)) + { + return LLUICtrl::handleMouseDown(x, y, mask); + } + + mDragging = true; + gFocusMgr.setMouseCapture(this); + setFocus(true); + + F32 hue, sat; + pointToPolar(x, y, hue, sat); + mLastHue = hue; + mModel.setPolar(hue, sat); + publish(); + return true; +} + +bool ALColorWheelCtrl::handleHover(S32 x, S32 y, MASK mask) +{ + if (gFocusMgr.getMouseCapture() != this || !mDragging) + { + getWindow()->setCursor(UI_CURSOR_ARROW); + return true; + } + + F32 hue, sat; + pointToPolar(x, y, hue, sat); + mLastHue = hue; + mModel.setPolar(hue, sat); + publish(); + + getWindow()->setCursor(UI_CURSOR_ARROW); + return true; +} + +bool ALColorWheelCtrl::handleMouseUp(S32 x, S32 y, MASK mask) +{ + if (gFocusMgr.getMouseCapture() != this) + { + return LLUICtrl::handleMouseUp(x, y, mask); + } + gFocusMgr.setMouseCapture(nullptr); + mDragging = false; + return true; +} + +bool ALColorWheelCtrl::handleDoubleClick(S32 x, S32 y, MASK mask) +{ + // Belt and braces, as handleMouseDown's enabled test is: parent dispatch + // already skips disabled children, so this cannot fire disabled today, + // but a reset that writes the setting must never ride on that staying so. + if (!getEnabled()) + { + return LLUICtrl::handleDoubleClick(x, y, mask); + } + + F32 cx, cy, radius; + wheelGeometry(cx, cy, radius); + const F32 dx = (F32)x - cx; + const F32 dy = (F32)y - cy; + if (sqrtf(dx * dx + dy * dy) <= radius) + { + // Double-clicking the face to neutralise is the gesture every grading + // tool uses, and it beats hunting for the reset glyph mid-drag. + onReset(); + return true; + } + return LLUICtrl::handleDoubleClick(x, y, mask); +} + +void ALColorWheelCtrl::setValue(const LLSD& value) +{ + if (!value.isArray() || value.size() < 3) + { + return; + } + mModel.setRGB(LLVector3((F32)value[0].asReal(), + (F32)value[1].asReal(), + (F32)value[2].asReal())); + if (mModel.getSat() > 0.f) + { + mLastHue = mModel.getHue(); + } + syncChildren(); +} + +LLSD ALColorWheelCtrl::getValue() const +{ + LLSD out = LLSD::emptyArray(); + for (S32 i = 0; i < 3; ++i) + { + out.append(LLSD::Real(mModel.getRGB().mV[i])); + } + return out; +} + +void ALColorWheelCtrl::publish() +{ + syncChildren(); + setControlValue(getValue()); + onCommit(); +} + +void ALColorWheelCtrl::syncChildren() +{ + // The children's commit handlers write back into the model, so a plain + // setValue here would loop. + if (mUpdating) + { + return; + } + mUpdating = true; + + if (mMaster) + { + mMaster->setValue(mModel.getMaster()); + } + for (S32 i = 0; i < 3; ++i) + { + if (mFields[i]) + { + mFields[i]->setText(llformat("%.*f", mDecimalDigits, mModel.getRGB().mV[i])); + } + } + + mUpdating = false; +} + +void ALColorWheelCtrl::onMasterCommit() +{ + if (mUpdating || !mMaster) + { + return; + } + mModel.setMaster((F32)mMaster->getValue().asReal()); + publish(); +} + +void ALColorWheelCtrl::onChannelCommit(S32 index) +{ + if (mUpdating || index < 0 || index > 2 || !mFields[index]) + { + return; + } + mModel.setChannel(index, (F32)atof(mFields[index]->getText().c_str())); + if (mModel.getSat() > 0.f) + { + mLastHue = mModel.getHue(); + } + publish(); +} + +void ALColorWheelCtrl::onReset() +{ + mModel.reset(); + mLastHue = 0.f; + publish(); +} + +void ALColorWheelCtrl::draw() +{ + F32 cx, cy, radius; + wheelGeometry(cx, cy, radius); + + if (radius > 1.f) + { + const F32 outer = radius + (F32)mRingThickness; + + // The hue ring. Washers draw around the current origin, so the + // translate is ours to do. + // + // It MUST be translateUI, paired with pushUIMatrix/popUIMatrix. Those + // save and restore the UI offset stack; translatef writes the modelview + // matrix, which only pushMatrix/popMatrix restore. Mixing the two + // leaves the translate applied forever, and since the UI draws in one + // pass everything after this widget -- the rest of the floater, the + // toolbars, the top-right buttons -- slides off screen. gl_circle_2d + // is the reference for this pairing; ALPanelMusicTicker uses the other + // one, pushMatrix with translatef, which is why it pre-scales by + // getUIScale() and this does not: vertex3f already applies + // (vertex + mUIOffset) * mUIScale. + gGL.pushUIMatrix(); + { + gGL.translateUI(cx, cy, 0.f); + gl_washer_angular_2d(outer, radius, mRingColors); + } + gGL.popUIMatrix(); + + // Face, then the neutral crosshair, then the puck on top. + gGL.color4fv(mFaceColor.get().mV); + gl_circle_2d(cx, cy, radius, 48, true); + + const LLColor4 cross = mCrosshairColor.get(); + gl_line_2d(ll_round(cx - radius), ll_round(cy), ll_round(cx + radius), ll_round(cy), cross); + gl_line_2d(ll_round(cx), ll_round(cy - radius), ll_round(cx), ll_round(cy + radius), cross); + + std::vector rim; + rim.reserve(48); + for (S32 i = 0; i < 48; ++i) + { + const F32 a = F_TWO_PI * (F32)i / 48.f; + rim.emplace_back(cx + cosf(a) * radius, cy + sinf(a) * radius); + } + gl_polyline_2d(rim, mBorderColor.get(), 1.f, true); + + // Puck position comes back out of the stored value, never from the + // pointer, so it can only ever sit where the setting actually is. + const F32 sat_max = mModel.getMaxSat(); + const F32 frac = (sat_max > 0.f) ? mModel.getSat() / sat_max : 0.f; + const F32 hue = (mModel.getSat() > 0.f) ? mModel.getHue() : mLastHue; + const F32 px = cx + cosf(hue) * frac * radius; + const F32 py = cy + sinf(hue) * frac * radius; + + const LLVector3 tint = ALColorWheelModel::ringColor(hue); + const LLColor4 puck_fill(tint.mV[VX], tint.mV[VY], tint.mV[VZ], 1.f); + + gGL.color4fv(puck_fill.mV); + gl_circle_2d(px, py, (F32)mPuckRadius, 16, true); + + std::vector ring; + ring.reserve(20); + for (S32 i = 0; i < 20; ++i) + { + const F32 a = F_TWO_PI * (F32)i / 20.f; + ring.emplace_back(px + cosf(a) * (F32)mPuckRadius, py + sinf(a) * (F32)mPuckRadius); + } + gl_polyline_2d(ring, mDragging ? LLColor4::white : mBorderColor.get(), 1.5f, true); + } + + LLUICtrl::draw(); +} diff --git a/indra/newview/alcolorwheelctrl.h b/indra/newview/alcolorwheelctrl.h new file mode 100644 index 0000000000..d5a8b312b0 --- /dev/null +++ b/indra/newview/alcolorwheelctrl.h @@ -0,0 +1,166 @@ +/** + * @file alcolorwheelctrl.h + * @brief Colourist's wheel: a hue ring with a draggable puck, a master slider + * and editable per-channel fields + * + * $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$ + */ + +#ifndef AL_COLORWHEELCTRL_H +#define AL_COLORWHEELCTRL_H + +#include "alcolorwheelmodel.h" +#include "lluictrl.h" +#include "lluicolor.h" + +#include +#include +#include + +class LLButton; +class LLLineEditor; +class LLSliderCtrl; +class LLTextBox; + +/** + * One wheel from a colourist's three-way, registered as @c color_wheel. + * + * Drag the puck to bias the colour: angle is hue, distance is how far. Drag + * the slider under it to move all three channels together. Type into the three + * fields for an exact value. All of it drives one RGB-triplet setting, and + * every route round-trips through the same decomposition, so they can never + * disagree -- see @ref ALColorWheelModel, which holds the maths and the tests. + * + * @par Binding + * Bind it like any other control: @c control_name="RenderColorGradeLift". The + * widget overrides @c setValue / @c getValue for a three-element LLSD array, + * which is all @c LLUICtrl::setControlVariable needs to wire the control's + * signal in both directions -- exactly how @c color_swatch already binds the + * split-tone tints. No naming contract, no floater-side glue. + * + * @par Flavours + * @c centre, @c min_value and @c max_value describe the setting behind it: + * lift is 0 over [-0.5, 0.5], gamma and gain are 1 over [0.5, 1.5]. Setting + * @c lock_master hides the slider, which is right for a split-tone tint + * because the renderer divides those by @c dot(tint, LUMA) -- their magnitude + * cancels, so a master there would visibly do nothing. + * + * @par Everything is a child + * The slider, three fields, caption, label and reset glyph are child widgets + * built here and laid out in @ref updateLayout, following LLColorSwatchCtrl. + * One XUI tag therefore yields a complete, consistent wheel, and a bank of + * them cannot drift apart the way six hand-placed widget groups would. + */ +class ALColorWheelCtrl final : public LLUICtrl +{ +public: + struct Params : public LLInitParam::Block + { + Optional label; + Optional centre; + Optional min_value; + Optional max_value; + Optional lock_master; + Optional ring_thickness; + Optional ring_steps; + Optional puck_radius; + Optional decimal_digits; + Optional border_color; + Optional face_color; + Optional crosshair_color; + + Params(); + }; + + ALColorWheelCtrl(const Params& p); + ~ALColorWheelCtrl() override = default; + + void draw() override; + void reshape(S32 width, S32 height, bool called_from_parent = true) override; + /// LLView::setEnabled only sets its own flag, and the slider and fields + /// draw from their own, so a bare enabled_control on this widget would + /// leave its children looking live. Propagate. + void setEnabled(bool enabled) override; + bool handleMouseDown(S32 x, S32 y, MASK mask) override; + bool handleMouseUp(S32 x, S32 y, MASK mask) override; + bool handleHover(S32 x, S32 y, MASK mask) override; + bool handleDoubleClick(S32 x, S32 y, MASK mask) override; + + /// Three reals, in channel order. Anything else is ignored, so a + /// mis-typed control_name degrades to an inert widget rather than a crash. + void setValue(const LLSD& value) override; + LLSD getValue() const override; + + const ALColorWheelModel& getModel() const { return mModel; } + +private: + /// Position the children and work out where the ring goes. Called on + /// construction and on every reshape. + void updateLayout(); + + /// Push the model's value out to the slider and the three fields, without + /// letting their commit handlers fire back. + void syncChildren(); + + /// Write the model's value to the bound control and fire the commit. + void publish(); + + void onMasterCommit(); + void onChannelCommit(S32 index); + void onReset(); + + /// Centre and radius of the hue ring, in local pixels. + void wheelGeometry(F32& cx, F32& cy, F32& radius) const; + + /// Pointer position to (hue, saturation), clamping to the rim rather than + /// refusing to move -- LLVirtualTrackball's set-on-click path returns + /// early outside its circle, which freezes the puck exactly when a + /// colourist is pushing for maximum. + bool pointToPolar(S32 x, S32 y, F32& hue_out, F32& sat_out) const; + + ALColorWheelModel mModel; + + LLTextBox* mLabel = nullptr; + LLButton* mReset = nullptr; + LLSliderCtrl* mMaster = nullptr; + std::array mFields{ nullptr, nullptr, nullptr }; + std::array mCaptions{ nullptr, nullptr, nullptr }; + + /// Ring colours, rebuilt only when the step count changes -- they depend + /// on the hue angle alone, never on the value. + std::vector mRingColors; + + LLUIColor mBorderColor; + LLUIColor mFaceColor; + LLUIColor mCrosshairColor; + + LLRect mWheelRect; + S32 mRingThickness; + S32 mRingSteps; + S32 mPuckRadius; + S32 mDecimalDigits; + bool mDragging = false; + bool mUpdating = false; + /// Held so a puck dragged exactly to the centre does not snap its hue to + /// zero and jump on the way back out. + F32 mLastHue = 0.f; +}; + +#endif // AL_COLORWHEELCTRL_H diff --git a/indra/newview/alcolorwheelmodel.cpp b/indra/newview/alcolorwheelmodel.cpp new file mode 100644 index 0000000000..2bd9668ea7 --- /dev/null +++ b/indra/newview/alcolorwheelmodel.cpp @@ -0,0 +1,191 @@ +/** + * @file alcolorwheelmodel.cpp + * @brief The maths behind a colour wheel + * + * $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$ + */ + +#include "linden_common.h" + +#include "alcolorwheelmodel.h" + +#include "llmath.h" + +#include + +namespace +{ +// The chroma plane's orthonormal basis. u points at red; v is the +// green-to-blue axis at right angles to it. Both are unit length and mutually +// perpendicular, and both are perpendicular to (1,1,1) -- which is what makes +// the whole decomposition a rotation, and therefore lossless. +// +// Written out rather than normalized at runtime so the constants can be read +// against the derivation: |(1, -1/2, -1/2)| = |(0, sqrt3/2, -sqrt3/2)| = sqrt(1.5). +const F32 INV_SQRT_1P5 = 0.81649658f; // 1 / sqrt(1.5) +const LLVector3 CHROMA_U(INV_SQRT_1P5, -0.5f * INV_SQRT_1P5, -0.5f * INV_SQRT_1P5); +const LLVector3 CHROMA_V(0.f, 0.70710678f, -0.70710678f); + +// Largest share of a unit deviation any single channel ever carries. It is +// exactly INV_SQRT_1P5, reached along the primary directions (0, 60, 120 ... +// degrees), and it is what turns a per-channel clamp into a hexagon: the +// reachable radius in the tightest direction is halfRange / this. +const F32 MAX_CHANNEL_SHARE = INV_SQRT_1P5; + +// How much chroma the ring is drawn with. Purely cosmetic -- it sets how vivid +// the ring looks, not what any value means. +const F32 RING_CHROMA = 0.45f; +} + +// static +LLVector3 ALColorWheelModel::chromaDirection(F32 hue) +{ + return CHROMA_U * cosf(hue) + CHROMA_V * sinf(hue); +} + +// static +F32 ALColorWheelModel::masterOf(const LLVector3& rgb) +{ + return (rgb.mV[VX] + rgb.mV[VY] + rgb.mV[VZ]) / 3.f; +} + +// static +const F32 ALColorWheelModel::MAX_CHROMA = 1.15470054f * 0.70710678f; // sqrt(2/3) + +// static +void ALColorWheelModel::toChroma(const LLVector3& rgb, F32& u_out, F32& v_out) +{ + const F32 m = masterOf(rgb); + const LLVector3 d(rgb.mV[VX] - m, rgb.mV[VY] - m, rgb.mV[VZ] - m); + + u_out = d * CHROMA_U; + v_out = d * CHROMA_V; +} + +// static +void ALColorWheelModel::toPolar(const LLVector3& rgb, F32& hue_out, F32& sat_out) +{ + F32 a, b; + toChroma(rgb, a, b); + + sat_out = sqrtf(a * a + b * b); + + // atan2(0,0) is implementation-defined and a neutral colour has no hue to + // report, so pin it rather than let the puck jump when it crosses centre. + hue_out = (sat_out > 0.f) ? atan2f(b, a) : 0.f; + if (hue_out < 0.f) + { + hue_out += F_TWO_PI; + } +} + +// static +LLVector3 ALColorWheelModel::toRGB(F32 master, F32 hue, F32 sat) +{ + const LLVector3 d = chromaDirection(hue) * sat; + return LLVector3(master + d.mV[VX], master + d.mV[VY], master + d.mV[VZ]); +} + +// static +LLVector3 ALColorWheelModel::ringColor(F32 hue) +{ + // Same chroma direction the puck uses, evaluated at a fixed mid grey. The + // ring therefore cannot disagree with the puck about where orange is, and + // it stays put while the master moves. + const LLVector3 d = chromaDirection(hue) * RING_CHROMA; + return LLVector3(llclamp(0.5f + d.mV[VX], 0.f, 1.f), + llclamp(0.5f + d.mV[VY], 0.f, 1.f), + llclamp(0.5f + d.mV[VZ], 0.f, 1.f)); +} + +void ALColorWheelModel::configure(F32 centre, F32 lo, F32 hi, bool master_locked) +{ + mCentre = centre; + mMin = llmin(lo, hi); + mMax = llmax(lo, hi); + mCentre = llclamp(mCentre, mMin, mMax); + mMasterLocked = master_locked; + setRGB(mRGB); +} + +F32 ALColorWheelModel::getMaxSat() const +{ + const F32 half_range = (mMax - mMin) * 0.5f; + return half_range / MAX_CHANNEL_SHARE; +} + +void ALColorWheelModel::setRGB(const LLVector3& rgb) +{ + mRGB.set(llclamp(rgb.mV[VX], mMin, mMax), + llclamp(rgb.mV[VY], mMin, mMax), + llclamp(rgb.mV[VZ], mMin, mMax)); +} + +F32 ALColorWheelModel::getMaster() const +{ + return masterOf(mRGB); +} + +F32 ALColorWheelModel::getHue() const +{ + F32 hue, sat; + toPolar(mRGB, hue, sat); + return hue; +} + +F32 ALColorWheelModel::getSat() const +{ + F32 hue, sat; + toPolar(mRGB, hue, sat); + return sat; +} + +void ALColorWheelModel::setMaster(F32 master) +{ + if (mMasterLocked) + { + return; + } + F32 hue, sat; + toPolar(mRGB, hue, sat); + setRGB(toRGB(llclamp(master, mMin, mMax), hue, sat)); +} + +void ALColorWheelModel::setPolar(F32 hue, F32 sat) +{ + const F32 master = mMasterLocked ? mCentre : getMaster(); + setRGB(toRGB(master, hue, llmax(sat, 0.f))); +} + +void ALColorWheelModel::setChannel(S32 index, F32 value) +{ + if (index < 0 || index > 2) + { + return; + } + LLVector3 rgb = mRGB; + rgb.mV[index] = value; + setRGB(rgb); +} + +void ALColorWheelModel::reset() +{ + mRGB.set(mCentre, mCentre, mCentre); +} diff --git a/indra/newview/alcolorwheelmodel.h b/indra/newview/alcolorwheelmodel.h new file mode 100644 index 0000000000..2e014bb0f9 --- /dev/null +++ b/indra/newview/alcolorwheelmodel.h @@ -0,0 +1,163 @@ +/** + * @file alcolorwheelmodel.h + * @brief The maths behind a colour wheel: RGB triplet <-> master, hue, saturation + * + * $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$ + */ + +#ifndef AL_COLORWHEELMODEL_H +#define AL_COLORWHEELMODEL_H + +#include "v3math.h" + +/** + * The shape half of @ref ALColorWheelCtrl. No GL, no LLUI, no viewer globals, + * so @c alcolorwheelmodel_test can exercise it directly. + * + * @par The decomposition + * A grading wheel offers three things to drag -- a master level, a hue and a + * saturation -- and the settings behind it are plain RGB triplets. Three + * degrees of freedom either way, and the bridge is the achromatic/chroma + * basis: + * + * @code + * master m = (r+g+b)/3 deviation d = (r,g,b) - m (sums to zero) + * u = normalize(1, -1/2, -1/2) v = normalize(0, +sqrt3/2, -sqrt3/2) + * hue = atan2(d.v, d.u) sat = |d| + * @endcode + * + * {(1,1,1)/sqrt3, u, v} is orthonormal, so this is a rotation of the RGB cube + * and nothing else -- which is why it **round-trips exactly**. Typing a value + * into a numeric field moves the puck to the right place, and dragging the + * puck writes back the same number the user would have typed. Nothing in the + * UI has to remember a "true" hue alongside the setting. + * + * The chroma-plane angle also lands on the usual colour circle: 0 is red, 120 + * degrees green, 240 blue, because pushing the deviation toward +R and away + * from G and B is exactly what "a red cast" means. So a conventional hue ring + * is honest here -- but @ref ringColor derives it from this same basis anyway, + * so the ring and the puck cannot drift apart. + * + * @par The reachable region is a hexagon, not a disc + * Each channel clamps independently, so the set of legal deviations is a + * regular hexagon in the chroma plane, not a circle. Its inradius is + * @c sqrt(1.5) * halfRange -- reached along the primary directions, where one + * channel carries 0.8165 of the unit deviation -- and its circumradius is + * @c sqrt(2) * halfRange at the corners. @ref getMaxSat returns the inradius, + * so a puck anywhere on the rim is always reachable; the corners sit slightly + * outside it and a typed value may legitimately put the puck a little past the + * ring. + * + * @par One model, four configurations + * Lift is centred on 0 over [-0.5, 0.5]; gamma and gain on 1 over [0.5, 1.5]; + * a split-tone tint on 0.5 over [0, 1] with the master locked. The tint case + * needs the lock because the renderer divides each tint by + * @c dot(tint, LUMA) (pipeline.cpp, @c tint_to_ratio), so tint *magnitude* + * cancels out entirely -- a master there would be a control that does nothing. + */ +class ALColorWheelModel +{ +public: + /// Unit deviation direction for a hue angle, in radians. The one function + /// both the puck and the ring go through. + static LLVector3 chromaDirection(F32 hue); + + /// Arithmetic mean of the three channels. + static F32 masterOf(const LLVector3& rgb); + + /// Cartesian coordinates of a triplet in the chroma plane, which is what + /// a wheel's puck sits on. @ref toPolar is this in polar form. + /// + /// Exposed so a vectorscope can plot pixels on the very plane the wheels + /// edit, without a second copy of the basis: push a wheel and the trace + /// must move the same way, or the two are lying about the same colour. + /// The magnitude never exceeds sqrt(2/3), the widest deviation any legal + /// triplet has -- see @ref MAX_CHROMA. + static void toChroma(const LLVector3& rgb, F32& u_out, F32& v_out); + + /// Largest chroma magnitude a triplet within a unit range can reach: + /// sqrt(2/3), at the RGB cube's corners. The bound a plot of this plane + /// scales to. + static const F32 MAX_CHROMA; + + /// Hue in [0, 2pi) and saturation (the deviation's magnitude) of a triplet. + static void toPolar(const LLVector3& rgb, F32& hue_out, F32& sat_out); + + /// Rebuild a triplet. Unclamped -- callers go through @ref setPolar to get + /// the channel clamp. + static LLVector3 toRGB(F32 master, F32 hue, F32 sat); + + ALColorWheelModel() = default; + + /// @param centre the neutral master value (0 for lift, 1 for gain, ...) + /// @param lo, hi the per-channel clamp the renderer applies + /// @param master_locked true for split-tone tints, whose magnitude the + /// shader normalises away + void configure(F32 centre, F32 lo, F32 hi, bool master_locked); + + F32 getCentre() const { return mCentre; } + F32 getMin() const { return mMin; } + F32 getMax() const { return mMax; } + bool isMasterLocked() const { return mMasterLocked; } + + /// Saturation at the ring's rim: the hexagon's inradius, so every hue is + /// reachable at radius 1. Independent of the current master on purpose -- + /// a ring that shrank as the master approached its limit would be unusable + /// exactly where a colourist needs it. Going past the rim is handled by + /// the channel clamp instead. + F32 getMaxSat() const; + + /// Store a triplet, clamped per channel. Everything else reads back from + /// the stored value, so the puck can never show a colour the setting is + /// not actually holding. + void setRGB(const LLVector3& rgb); + const LLVector3& getRGB() const { return mRGB; } + + F32 getMaster() const; + F32 getHue() const; + F32 getSat() const; + + /// Move the master, keeping hue and saturation. Ignored when locked. + void setMaster(F32 master); + + /// Move the puck, keeping the master. @a sat is in value units, not ring + /// fractions; pass @c getMaxSat() * radius. + void setPolar(F32 hue, F32 sat); + + /// Set one channel, as a numeric field does. + void setChannel(S32 index, F32 value); + + /// Back to neutral: master at the centre, no chroma. + void reset(); + + /// What the ring should show at this angle -- the same chroma direction + /// the puck would produce, rendered at a fixed reference so the ring stays + /// stable while the master moves. + static LLVector3 ringColor(F32 hue); + +private: + LLVector3 mRGB{ 0.f, 0.f, 0.f }; + F32 mCentre = 0.f; + F32 mMin = -0.5f; + F32 mMax = 0.5f; + bool mMasterLocked = false; +}; + +#endif // AL_COLORWHEELMODEL_H diff --git a/indra/newview/alcurveeditorctrl.cpp b/indra/newview/alcurveeditorctrl.cpp new file mode 100644 index 0000000000..fbdb303cd1 --- /dev/null +++ b/indra/newview/alcurveeditorctrl.cpp @@ -0,0 +1,343 @@ +/** + * @file alcurveeditorctrl.cpp + * @brief XUI widget that plots a curve and lets the user drag its control points + * + * $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$ + */ + +#include "llviewerprecompiledheaders.h" + +#include "alcurveeditorctrl.h" + +#include "llfocusmgr.h" +#include "lllocalcliprect.h" +#include "llrender.h" +#include "llrender2dutils.h" +#include "lluicolortable.h" +#include "llwindow.h" + +static LLDefaultChildRegistry::Register r("curve_editor"); + +namespace +{ +// How far from a handle's centre a click still counts, on top of its drawn +// radius. A 4px handle is a small target on a high-DPI display, and missing it +// silently starts no drag at all, so the grab area is deliberately generous. +constexpr S32 GRAB_SLOP = 4; +} + +ALCurveEditorCtrl::Params::Params() +: background_color("background_color", LLUIColorTable::instance().getColor("PanelDefaultBackgroundColor")), + border_color("border_color", LLUIColorTable::instance().getColor("DefaultShadowLight")), + grid_color("grid_color", LLUIColorTable::instance().getColor("DefaultShadowDark")), + curve_color("curve_color", LLUIColorTable::instance().getColor("White")), + handle_color("handle_color", LLUIColorTable::instance().getColor("White")), + grid_divisions("grid_divisions", 4), + curve_samples("curve_samples", 96), + handle_radius("handle_radius", 4), + curve_width("curve_width", 1.6f), + draw_diagonal("draw_diagonal", true) +{ + changeDefault(mouse_opaque, true); +} + +ALCurveEditorCtrl::ALCurveEditorCtrl(const ALCurveEditorCtrl::Params& p) +: LLUICtrl(p), + mBackgroundColor(p.background_color), + mBorderColor(p.border_color), + mGridColor(p.grid_color), + mCurveColor(p.curve_color), + mHandleColor(p.handle_color), + mGridDivisions(p.grid_divisions), + mCurveSamples(llmax(2, p.curve_samples())), + mHandleRadius(llmax(2, p.handle_radius())), + mCurveWidth(llmax(0.5f, p.curve_width())), + mDrawDiagonal(p.draw_diagonal) +{ +} + +void ALCurveEditorCtrl::addGhostCurve(curve_fn_t fn, const LLColor4& color) +{ + mGhosts.emplace_back(std::move(fn), color); +} + +void ALCurveEditorCtrl::addFillCurve(curve_fn_t fn, const LLColor4& color) +{ + mFills.emplace_back(std::move(fn), color); +} + +std::string ALCurveEditorCtrl::getActiveHandleName() const +{ + if (mDragIndex < 0 || mDragIndex >= (S32)mHandles.size()) + { + return std::string(); + } + // By value on purpose: a consumer typically reacts to the commit by + // writing a setting and calling setHandles(), which would leave a + // reference into the old vector dangling. + return mHandles[mDragIndex].mName; +} + +LLRect ALCurveEditorCtrl::plotRect() const +{ + LLRect rect = getLocalRect(); + rect.stretch(-mHandleRadius); + return rect; +} + +LLVector2 ALCurveEditorCtrl::graphToPixelF(F32 gx, F32 gy) const +{ + const LLRect plot = plotRect(); + return LLVector2(plot.mLeft + llclamp(gx, 0.f, 1.f) * (F32)llmax(1, plot.getWidth()), + plot.mBottom + llclamp(gy, 0.f, 1.f) * (F32)llmax(1, plot.getHeight())); +} + +void ALCurveEditorCtrl::graphToPixel(F32 gx, F32 gy, S32& px, S32& py) const +{ + const LLVector2 p = graphToPixelF(gx, gy); + px = ll_round(p.mV[VX]); + py = ll_round(p.mV[VY]); +} + +void ALCurveEditorCtrl::pixelToGraph(S32 px, S32 py, F32& gx, F32& gy) const +{ + const LLRect plot = plotRect(); + gx = llclamp((F32)(px - plot.mLeft) / (F32)llmax(1, plot.getWidth()), 0.f, 1.f); + gy = llclamp((F32)(py - plot.mBottom) / (F32)llmax(1, plot.getHeight()), 0.f, 1.f); +} + +S32 ALCurveEditorCtrl::hitTest(S32 px, S32 py) const +{ + const S32 grab = mHandleRadius + GRAB_SLOP; + S32 best = -1; + S32 best_dist_sq = grab * grab + 1; + + for (S32 i = 0; i < (S32)mHandles.size(); ++i) + { + S32 hx, hy; + graphToPixel(mHandles[i].mX, mHandles[i].mY, hx, hy); + const S32 dx = px - hx; + const S32 dy = py - hy; + const S32 dist_sq = dx * dx + dy * dy; + // Strictly nearer, so when handles overlap the earlier one wins and a + // drag does not flicker between them as the pointer moves. + if (dist_sq < best_dist_sq) + { + best_dist_sq = dist_sq; + best = i; + } + } + return best; +} + +bool ALCurveEditorCtrl::handleMouseDown(S32 x, S32 y, MASK mask) +{ + // Belt and braces: parent dispatch already skips disabled children + // (visibleEnabledAndContains, llview.cpp), so this cannot be reached + // disabled today. It is here so a drag can never commit through a greyed + // widget if that dispatch ever changes. + if (!getEnabled()) + { + return LLUICtrl::handleMouseDown(x, y, mask); + } + + const S32 index = hitTest(x, y); + if (index < 0) + { + return LLUICtrl::handleMouseDown(x, y, mask); + } + + mDragIndex = index; + + // Grab by the offset from the handle's centre rather than snapping the + // handle to the pointer, so a click a few pixels off does not jog the + // value before the drag has even started. + S32 hx, hy; + graphToPixel(mHandles[index].mX, mHandles[index].mY, hx, hy); + mGrabOffsetX = x - hx; + mGrabOffsetY = y - hy; + + gFocusMgr.setMouseCapture(this); + setFocus(true); + return true; +} + +bool ALCurveEditorCtrl::handleHover(S32 x, S32 y, MASK mask) +{ + // Bounds-checked every time rather than trusted: the commit below hands + // control to the consumer, which is free to replace the handle list. + if (gFocusMgr.getMouseCapture() != this || mDragIndex < 0 || mDragIndex >= (S32)mHandles.size()) + { + getWindow()->setCursor(UI_CURSOR_ARROW); + return true; + } + + F32 gx, gy; + pixelToGraph(x - mGrabOffsetX, y - mGrabOffsetY, gx, gy); + + if (!mHandles[mDragIndex].mLockX) + { + mHandles[mDragIndex].mX = gx; + } + if (!mHandles[mDragIndex].mLockY) + { + mHandles[mDragIndex].mY = gy; + } + + // Commit per move: the consumer writes its setting, the renderer picks it + // up next frame, and the drag previews live. The handle list is the + // widget's own state, so a consumer that clamps differently is free to + // write back a corrected position via setHandles(). + onCommit(); + + getWindow()->setCursor(UI_CURSOR_ARROW); + return true; +} + +bool ALCurveEditorCtrl::handleMouseUp(S32 x, S32 y, MASK mask) +{ + if (gFocusMgr.getMouseCapture() != this) + { + return LLUICtrl::handleMouseUp(x, y, mask); + } + + gFocusMgr.setMouseCapture(nullptr); + mDragIndex = -1; + return true; +} + +std::vector ALCurveEditorCtrl::sampleCurve(const curve_fn_t& fn) const +{ + std::vector pts; + + const LLRect plot = plotRect(); + if (!fn || plot.getWidth() <= 0 || plot.getHeight() <= 0) + { + return pts; + } + + // Sampled in float. Rounding to whole pixels here would quantise the curve + // before it was drawn, so the anti-aliasing would only soften stair steps + // the widget had introduced itself. + pts.reserve(mCurveSamples); + for (S32 i = 0; i < mCurveSamples; ++i) + { + const F32 gx = (F32)i / (F32)(mCurveSamples - 1); + pts.push_back(graphToPixelF(gx, fn(gx))); + } + return pts; +} + +void ALCurveEditorCtrl::drawCurve(const curve_fn_t& fn, const LLColor4& color) const +{ + const std::vector pts = sampleCurve(fn); + if (pts.empty()) + { + return; + } + gl_polyline_2d(pts, color, mCurveWidth); +} + +void ALCurveEditorCtrl::drawFill(const curve_fn_t& fn, const LLColor4& color) const +{ + const std::vector pts = sampleCurve(fn); + if (pts.empty()) + { + return; + } + // Down to graph y = 0, not to the widget's bottom edge: the plot is inset + // by the handle radius, so a fill to the frame would sit below its own + // axis and the bands would look like they never reach zero. + gl_polyfill_2d(pts, graphToPixelF(0.f, 0.f).mV[VY], color); +} + +void ALCurveEditorCtrl::draw() +{ + const LLRect bounds = getLocalRect(); + const LLRect plot = plotRect(); + + gl_rect_2d(bounds, mBackgroundColor.get(), true); + + LLLocalClipRect clip(bounds); + + if (mGridDivisions > 0 && plot.getWidth() > 0 && plot.getHeight() > 0) + { + const LLColor4 grid = mGridColor.get(); + for (S32 i = 1; i < mGridDivisions; ++i) + { + const F32 f = (F32)i / (F32)mGridDivisions; + S32 px, py; + graphToPixel(f, f, px, py); + gl_line_2d(px, plot.mBottom, px, plot.mTop, grid); + gl_line_2d(plot.mLeft, py, plot.mRight, py, grid); + } + } + + if (mDrawDiagonal) + { + // The identity. Without it there is no way to see at a glance whether + // the curve is lifting or crushing a given input. Drawn as a ribbon + // like the curves: the grid lines above are axis-aligned and land on + // pixel boundaries, but a 45-degree line is the worst case for + // stair-stepping. + const std::vector diagonal = { graphToPixelF(0.f, 0.f), graphToPixelF(1.f, 1.f) }; + gl_polyline_2d(diagonal, mGridColor.get(), 1.f); + } + + // Fills first, so the grid reads through them and the curves sit on top. + for (const auto& fill : mFills) + { + drawFill(fill.first, fill.second); + } + + for (const auto& ghost : mGhosts) + { + drawCurve(ghost.first, ghost.second); + } + drawCurve(mCurve, mCurveColor.get()); + + for (S32 i = 0; i < (S32)mHandles.size(); ++i) + { + const Handle& handle = mHandles[i]; + const LLVector2 c = graphToPixelF(handle.mX, handle.mY); + + // Filled disc first, then an anti-aliased ring over its edge. The + // fan's own outline is aliased and there is no cheap way to feather a + // fan, but the ring sits exactly on that edge and covers it. + const bool active = (i == mDragIndex); + gGL.color4fv(handle.mColor.mV); + gl_circle_2d(c.mV[VX], c.mV[VY], (F32)mHandleRadius, 16, true); + + constexpr S32 RING_STEPS = 20; + std::vector ring; + ring.reserve(RING_STEPS); + for (S32 step = 0; step < RING_STEPS; ++step) + { + const F32 a = F_TWO_PI * (F32)step / (F32)RING_STEPS; + ring.emplace_back(c.mV[VX] + cosf(a) * (F32)mHandleRadius, + c.mV[VY] + sinf(a) * (F32)mHandleRadius); + } + gl_polyline_2d(ring, active ? LLColor4::white : mHandleColor.get(), 1.5f, true); + } + + gl_rect_2d(bounds, mBorderColor.get(), false); + + LLUICtrl::draw(); +} diff --git a/indra/newview/alcurveeditorctrl.h b/indra/newview/alcurveeditorctrl.h new file mode 100644 index 0000000000..e3669e5640 --- /dev/null +++ b/indra/newview/alcurveeditorctrl.h @@ -0,0 +1,164 @@ +/** + * @file alcurveeditorctrl.h + * @brief XUI widget that plots a curve and lets the user drag its control points + * + * $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$ + */ + +#ifndef AL_CURVEEDITORCTRL_H +#define AL_CURVEEDITORCTRL_H + +#include "lluictrl.h" +#include "lluicolor.h" + +#include +#include +#include + +/** + * A graph with draggable handles. Registered as @c curve_editor. + * + * The widget owns no curve of its own: a consumer hands it a sampling function + * and a list of handles, and it draws the one and drags the others. That split + * is what makes it reusable -- a tone curve, a falloff, a day-cycle envelope + * and a gain ramp all differ in what the curve *means*, not in what the graph + * has to do. @ref ALCurveModel supplies a ready function for the two shapes + * that already have consumers. + * + * Both axes run 0..1 with y up, i.e. graph coordinates, not screen ones. + * + * Handles carry axis locks because most real curves have some. A filmic toe + * moves only horizontally; a locked spline endpoint moves only vertically. + * Rather than have every consumer re-derive that from its own model, the lock + * rides on the handle and the drag honours it. + * + * A drag fires the commit callback on every mouse move (so the preview tracks + * the pointer, which is the whole point of a graph) with @ref getActiveHandle + * naming which one moved. + */ +class ALCurveEditorCtrl final : public LLUICtrl +{ +public: + /// A draggable point on the graph. + struct Handle + { + F32 mX = 0.f; ///< 0..1, left to right + F32 mY = 0.f; ///< 0..1, bottom to top + bool mLockX = false; ///< drag cannot change x + bool mLockY = false; ///< drag cannot change y + std::string mName; ///< reported to the consumer on commit + LLColor4 mColor = LLColor4::white; + }; + + /// A curve to plot. The primary one is drawn solid; ghosts are drawn thin + /// and dim, which is how a channel editor shows the two channels you are + /// not currently editing. + typedef std::function curve_fn_t; + + struct Params : public LLInitParam::Block + { + Optional background_color; + Optional border_color; + Optional grid_color; + Optional curve_color; + Optional handle_color; + Optional grid_divisions; + Optional curve_samples; + Optional handle_radius; + Optional curve_width; + Optional draw_diagonal; + + Params(); + }; + + ALCurveEditorCtrl(const Params& p); + ~ALCurveEditorCtrl() override = default; + + void draw() override; + bool handleMouseDown(S32 x, S32 y, MASK mask) override; + bool handleMouseUp(S32 x, S32 y, MASK mask) override; + bool handleHover(S32 x, S32 y, MASK mask) override; + + /// The curve to plot. Passing an empty function draws handles only. + void setCurve(curve_fn_t fn) { mCurve = std::move(fn); } + + /// Dimmed reference curves, drawn behind the primary one. + void clearGhostCurves() { mGhosts.clear(); } + void addGhostCurve(curve_fn_t fn, const LLColor4& color); + + /// Curves drawn as a filled area down to y = 0, behind everything else. + /// + /// For a graph whose subject is coverage rather than shape -- the split + /// tone bands, where the question is which tones a tint reaches and how + /// strongly -- three outlines read as three crossing lines and a filled + /// band reads immediately. Pass a translucent colour; they overlap. + void clearFillCurves() { mFills.clear(); } + void addFillCurve(curve_fn_t fn, const LLColor4& color); + + void setHandles(std::vector handles) { mHandles = std::move(handles); } + const std::vector& getHandles() const { return mHandles; } + + /// Which handle the pointer is dragging, or -1 between drags. Valid inside + /// the commit callback; that is how a consumer tells one handle's move + /// from another's without diffing the whole list. + S32 getActiveHandle() const { return mDragIndex; } + std::string getActiveHandleName() const; + +private: + /// Graph coordinates (0..1, y up) to local widget pixels and back. The + /// plot is inset by the handle radius so a handle sitting on 0 or 1 is + /// fully drawn instead of half-clipped by the border. + LLRect plotRect() const; + LLVector2 graphToPixelF(F32 gx, F32 gy) const; + void graphToPixel(F32 gx, F32 gy, S32& px, S32& py) const; + void pixelToGraph(S32 px, S32 py, F32& gx, F32& gy) const; + + /// Nearest handle to a pixel within grab range, or -1. + S32 hitTest(S32 px, S32 py) const; + + /// Sample a curve across the plot, in pixels. Shared so a fill and its + /// outline cannot disagree about where the curve runs. + std::vector sampleCurve(const curve_fn_t& fn) const; + + void drawCurve(const curve_fn_t& fn, const LLColor4& color) const; + void drawFill(const curve_fn_t& fn, const LLColor4& color) const; + + curve_fn_t mCurve; + std::vector> mGhosts; + std::vector> mFills; + std::vector mHandles; + + LLUIColor mBackgroundColor; + LLUIColor mBorderColor; + LLUIColor mGridColor; + LLUIColor mCurveColor; + LLUIColor mHandleColor; + S32 mGridDivisions; + S32 mCurveSamples; + S32 mHandleRadius; + F32 mCurveWidth; + bool mDrawDiagonal; + + S32 mDragIndex = -1; + S32 mGrabOffsetX = 0; + S32 mGrabOffsetY = 0; +}; + +#endif // AL_CURVEEDITORCTRL_H diff --git a/indra/newview/alcurvemodel.cpp b/indra/newview/alcurvemodel.cpp new file mode 100644 index 0000000000..56ae431c80 --- /dev/null +++ b/indra/newview/alcurvemodel.cpp @@ -0,0 +1,339 @@ +/** + * @file alcurvemodel.cpp + * @brief Curve shapes for the curve editor widget + * + * $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$ + */ + +#include "linden_common.h" + +#include "alcurvemodel.h" + +#include "llmath.h" + +#include +#include + +// static +F32 ALCurveModel::smoothstep(F32 x, F32 toe, F32 shoulder, F32 strength) +{ + // cg_sCurve, colorGradeUtilF.glsl. The reciprocal is guarded the same way + // pipeline.cpp guards it when it uploads uCurveInvRange, so a shoulder at + // or below the toe degenerates identically on both sides: the ramp becomes + // a step at the toe rather than a division by zero. + const F32 inv_range = 1.f / llmax(shoulder - toe, 1e-4f); + const F32 t = llclamp((x - toe) * inv_range, 0.f, 1.f); + const F32 s = t * t * (3.f - 2.f * t); + const F32 k = llclamp(strength, 0.f, 1.f); + return x + (s - x) * k; +} + +namespace +{ +/// GLSL's smoothstep: the clamped Hermite, edges given rather than derived. +/// Guarded against a zero-width pair, which GLSL leaves undefined; ours are +/// always SPLIT_TONE_HALF_WIDTH apart, so the guard never engages in practice +/// and exists so a future caller cannot make it divide by zero. +F32 hermiteStep(F32 edge0, F32 edge1, F32 x) +{ + const F32 t = llclamp((x - edge0) / llmax(edge1 - edge0, 1e-6f), 0.f, 1.f); + return t * t * (3.f - 2.f * t); +} +} // namespace + +// static +ALCurveModel::SplitToneWeights ALCurveModel::splitToneWeights(F32 l, F32 mid) +{ + // applySplitToning, colorGradeUtilF.glsl. + SplitToneWeights w; + w.mHighlight = hermiteStep(mid, mid + SPLIT_TONE_HALF_WIDTH, l); + w.mShadow = 1.f - hermiteStep(mid - SPLIT_TONE_HALF_WIDTH, mid, l); + w.mMidtone = llmax(1.f - w.mHighlight - w.mShadow, 0.f); + return w; +} + +// static +F32 ALCurveModel::splitToneMid(F32 balance) +{ + return 0.5f + llclamp(balance, -1.f, 1.f) * 0.4f; +} + +// static +F32 ALCurveModel::splitToneBalance(F32 mid) +{ + return llclamp((mid - 0.5f) / 0.4f, -1.f, 1.f); +} + +void ALCurveModel::setSmoothstep(F32 toe, F32 shoulder, F32 strength) +{ + mToe = toe; + mShoulder = shoulder; + mStrength = strength; +} + +void ALCurveModel::setPoints(std::vector points) +{ + if (points.size() < 2) + { + // A curve needs two ends. Anything shorter is treated as "reset". + mPoints = { { 0.f, 0.f }, { 1.f, 1.f } }; + return; + } + mPoints = std::move(points); + normalize(); +} + +S32 ALCurveModel::addPoint(F32 x, F32 y) +{ + Point p; + p.mX = llclamp(x, 0.f, 1.f); + p.mY = llclamp(y, 0.f, 1.f); + + auto it = std::upper_bound(mPoints.begin(), mPoints.end(), p, + [](const Point& a, const Point& b) { return a.mX < b.mX; }); + const S32 index = static_cast(it - mPoints.begin()); + mPoints.insert(it, p); + normalize(); + return index; +} + +bool ALCurveModel::removePoint(S32 index) +{ + if (index < 0 || index >= getPointCount() || getPointCount() <= 2) + { + return false; + } + if (mEndpointsLocked && (index == 0 || index == getPointCount() - 1)) + { + return false; + } + mPoints.erase(mPoints.begin() + index); + return true; +} + +S32 ALCurveModel::movePoint(S32 index, F32 x, F32 y) +{ + if (index < 0 || index >= getPointCount()) + { + return index; + } + + const S32 last = getPointCount() - 1; + F32 lo = 0.f; + F32 hi = 1.f; + if (index > 0) + { + lo = mPoints[index - 1].mX + MIN_POINT_GAP; + } + if (index < last) + { + hi = mPoints[index + 1].mX - MIN_POINT_GAP; + } + + if (mEndpointsLocked && (index == 0 || index == last)) + { + // Pinned horizontally: the ends define the domain, so only their + // height is the user's to set. + x = (index == 0) ? 0.f : 1.f; + } + else if (lo > hi) + { + // Neighbours are already as close as MIN_POINT_GAP allows; there is + // nowhere legal to go, so hold position. + x = mPoints[index].mX; + } + else + { + x = llclamp(x, lo, hi); + } + + mPoints[index].mX = x; + mPoints[index].mY = llclamp(y, 0.f, 1.f); + return index; +} + +void ALCurveModel::setEndpointsLocked(bool locked) +{ + mEndpointsLocked = locked; + if (locked && getPointCount() >= 2) + { + mPoints.front().mX = 0.f; + mPoints.back().mX = 1.f; + normalize(); + } +} + +void ALCurveModel::normalize() +{ + std::stable_sort(mPoints.begin(), mPoints.end(), + [](const Point& a, const Point& b) { return a.mX < b.mX; }); + + for (Point& p : mPoints) + { + p.mX = llclamp(p.mX, 0.f, 1.f); + p.mY = llclamp(p.mY, 0.f, 1.f); + } + + if (mEndpointsLocked && mPoints.size() >= 2) + { + mPoints.front().mX = 0.f; + mPoints.back().mX = 1.f; + } + + // Push coincident points apart from the left. Walking left to right keeps + // the ordering the sort established; the trailing clamp to 1 can only + // matter if a caller supplied more points than 1/MIN_POINT_GAP allows, in + // which case the tail collapses onto 1 and evaluate() still terminates. + for (size_t i = 1; i < mPoints.size(); ++i) + { + const F32 floor_x = mPoints[i - 1].mX + MIN_POINT_GAP; + if (mPoints[i].mX < floor_x) + { + mPoints[i].mX = llmin(floor_x, 1.f); + } + } +} + +F32 ALCurveModel::evaluate(F32 x) const +{ + x = llclamp(x, 0.f, 1.f); + + if (mKind == KIND_SMOOTHSTEP) + { + return smoothstep(x, mToe, mShoulder, mStrength); + } + + const size_t n = mPoints.size(); + if (n == 0) + { + return x; + } + if (n == 1) + { + return mPoints[0].mY; + } + if (x <= mPoints.front().mX) + { + return mPoints.front().mY; + } + if (x >= mPoints.back().mX) + { + return mPoints.back().mY; + } + + // Locate the segment. Point counts here are small (a tone curve is single + // digits), so a linear scan beats the constant factor of a binary search. + size_t i = 0; + while (i + 2 < n && mPoints[i + 1].mX <= x) + { + ++i; + } + + const F32 h = mPoints[i + 1].mX - mPoints[i].mX; + if (h <= 0.f) + { + return mPoints[i + 1].mY; + } + const F32 delta = (mPoints[i + 1].mY - mPoints[i].mY) / h; + + // Fritsch-Carlson tangents for this segment's two ends. Computed locally + // rather than cached because the point list is tiny and a cache would be + // one more thing to invalidate on every drag tick. + auto secant = [this, n](size_t k) -> F32 + { + if (k + 1 >= n) + { + return 0.f; + } + const F32 dx = mPoints[k + 1].mX - mPoints[k].mX; + return (dx > 0.f) ? (mPoints[k + 1].mY - mPoints[k].mY) / dx : 0.f; + }; + + auto tangent = [&](size_t k) -> F32 + { + if (k == 0) + { + return secant(0); + } + if (k == n - 1) + { + return secant(n - 2); + } + const F32 d_prev = secant(k - 1); + const F32 d_next = secant(k); + // A local extremum must stay one: averaging across a sign change is + // exactly what produces the overshoot this spline exists to avoid. + if (d_prev * d_next <= 0.f) + { + return 0.f; + } + return 0.5f * (d_prev + d_next); + }; + + F32 m0 = tangent(i); + F32 m1 = tangent(i + 1); + + if (delta == 0.f) + { + m0 = 0.f; + m1 = 0.f; + } + else + { + // Keep (m0, m1) inside the circle of radius 3 in units of delta; past + // it the Hermite segment is no longer monotone. + const F32 alpha = m0 / delta; + const F32 beta = m1 / delta; + const F32 sum_sq = alpha * alpha + beta * beta; + if (sum_sq > 9.f) + { + const F32 tau = 3.f / sqrtf(sum_sq); + m0 = tau * alpha * delta; + m1 = tau * beta * delta; + } + } + + const F32 t = (x - mPoints[i].mX) / h; + const F32 t2 = t * t; + const F32 t3 = t2 * t; + const F32 h00 = 2.f * t3 - 3.f * t2 + 1.f; + const F32 h10 = t3 - 2.f * t2 + t; + const F32 h01 = -2.f * t3 + 3.f * t2; + const F32 h11 = t3 - t2; + + const F32 y = h00 * mPoints[i].mY + h10 * h * m0 + + h01 * mPoints[i + 1].mY + h11 * h * m1; + return llclamp(y, 0.f, 1.f); +} + +void ALCurveModel::sample(std::vector& out, S32 count) const +{ + out.clear(); + if (count < 2) + { + return; + } + out.reserve(count); + const F32 step = 1.f / static_cast(count - 1); + for (S32 i = 0; i < count; ++i) + { + out.push_back(evaluate(static_cast(i) * step)); + } +} diff --git a/indra/newview/alcurvemodel.h b/indra/newview/alcurvemodel.h new file mode 100644 index 0000000000..f6f67a716d --- /dev/null +++ b/indra/newview/alcurvemodel.h @@ -0,0 +1,172 @@ +/** + * @file alcurvemodel.h + * @brief Curve shapes for the curve editor widget: an N-point spline and the + * renderer's filmic S-curve + * + * $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$ + */ + +#ifndef AL_CURVEMODEL_H +#define AL_CURVEMODEL_H + +#include "stdtypes.h" + +#include + +/** + * The shape half of the curve editor: control points in, a function out. + * + * Deliberately free of UI, GL and viewer globals so it can be exercised + * directly by @c alcurvemodel_test. @ref ALCurveEditorCtrl owns the pixels and + * the mouse; everything that decides *what the curve does* lives here. + * + * Two kinds share one @ref evaluate: + * + * - @c KIND_SMOOTHSTEP mirrors @c cg_sCurve in + * @c class1/alchemy/colorGradeUtilF.glsl bit for bit, including the CPU-side + * @c 1/max(shoulder-toe, 1e-4) that pipeline.cpp pre-computes. A graph of it + * is therefore a picture of what the shader will actually do, not an + * illustration of it. If either side is ever retuned, @c alcurvemodel_test + * is where the divergence surfaces. + * - @c KIND_SPLINE is a monotone cubic (Fritsch-Carlson) through arbitrary + * control points. Monotone rather than natural or Catmull-Rom because a tone + * curve that overshoots between two points inverts contrast there: drag one + * handle down and a band *above* it gets brighter. Fritsch-Carlson clamps the + * tangents so that can never happen, at the cost of a little smoothness. + */ +class ALCurveModel +{ +public: + enum EKind + { + KIND_SMOOTHSTEP, ///< toe / shoulder / strength, as the renderer does it + KIND_SPLINE, ///< monotone cubic through N control points + }; + + struct Point + { + F32 mX = 0.f; + F32 mY = 0.f; + }; + + /// Smallest gap kept between neighbouring control points, so a segment can + /// never have zero width and the Hermite division stays finite. + static constexpr F32 MIN_POINT_GAP = 1e-3f; + + ALCurveModel() = default; + + EKind getKind() const { return mKind; } + void setKind(EKind k) { mKind = k; } + + /// @name Smoothstep + /// @{ + void setSmoothstep(F32 toe, F32 shoulder, F32 strength); + F32 getToe() const { return mToe; } + F32 getShoulder() const { return mShoulder; } + F32 getStrength() const { return mStrength; } + /// @} + + /// @name Spline control points + /// Points are kept sorted by x. Movement clamps into the gap between the + /// neighbours rather than reordering, which is what every other curve + /// editor does and what keeps a drag predictable. + /// @{ + + /// Replace the point list. Sorted and separated on the way in. + void setPoints(std::vector points); + const std::vector& getPoints() const { return mPoints; } + S32 getPointCount() const { return static_cast(mPoints.size()); } + + /// Insert a point, returning its index after ordering. + S32 addPoint(F32 x, F32 y); + + /// Remove a point. Refuses to leave fewer than two, and refuses to remove + /// an endpoint while the endpoints are locked. + bool removePoint(S32 index); + + /// Move a point, clamping it into its neighbours' gap and into 0..1. + /// Returns the index it ended up at (unchanged; ordering is preserved). + S32 movePoint(S32 index, F32 x, F32 y); + + /// When locked (the default), the first point's x is pinned to 0 and the + /// last point's x to 1, so the curve always spans the full input range. + void setEndpointsLocked(bool locked); + bool getEndpointsLocked() const { return mEndpointsLocked; } + /// @} + + /// The curve's value at @a x. Outside 0..1 the input is clamped, so the + /// result is flat beyond the ends rather than extrapolating. + F32 evaluate(F32 x) const; + + /// Fill @a out with @a count evenly spaced samples over 0..1 inclusive, + /// so out[0] is evaluate(0) and out[count-1] is evaluate(1). @a count below + /// 2 yields an empty result. + void sample(std::vector& out, S32 count) const; + + /// The renderer's per-channel filmic curve, exactly as the shader computes + /// it. Kept static so callers with loose parameters (a settings triplet, + /// say) need not build a model first. + static F32 smoothstep(F32 x, F32 toe, F32 shoulder, F32 strength); + + /// @name Split-tone bands + /// The three luma masks @c applySplitToning blends through, so a graph of + /// them is a picture of which tones each tint actually reaches. Same + /// contract as @ref smoothstep: it mirrors the shader, and + /// @c alcurvemodel_test is where a divergence surfaces. + /// @{ + + /// Half-width of the shadow and highlight ramps. Hard-coded in the shader, + /// so it is hard-coded here too rather than invented as a setting. + static constexpr F32 SPLIT_TONE_HALF_WIDTH = 0.35f; + + struct SplitToneWeights + { + F32 mShadow = 0.f; + F32 mMidtone = 0.f; + F32 mHighlight = 0.f; + }; + + /// Weights at luma @a l for a split point @a mid. The three always sum to + /// exactly 1: the shadow and highlight ramps meet at @a mid without + /// overlapping, so the midtone remainder is never clamped away. + static SplitToneWeights splitToneWeights(F32 l, F32 mid); + + /// The CPU's balance -> split point mapping, and its inverse. pipeline.cpp + /// uploads @c 0.5 + balance * 0.4, so balance +-1 puts the split at 0.9 or + /// 0.1 and the graph's handle can be read straight back into the setting. + static F32 splitToneMid(F32 balance); + static F32 splitToneBalance(F32 mid); + /// @} + +private: + /// Sort by x and push apart any pair closer than MIN_POINT_GAP. + void normalize(); + + EKind mKind = KIND_SMOOTHSTEP; + + F32 mToe = 0.f; + F32 mShoulder = 1.f; + F32 mStrength = 0.f; + + std::vector mPoints{ { 0.f, 0.f }, { 1.f, 1.f } }; + bool mEndpointsLocked = true; +}; + +#endif // AL_CURVEMODEL_H diff --git a/indra/newview/aldaycyclelandmarks.cpp b/indra/newview/aldaycyclelandmarks.cpp new file mode 100644 index 0000000000..f911680321 --- /dev/null +++ b/indra/newview/aldaycyclelandmarks.cpp @@ -0,0 +1,129 @@ +/** + * @file aldaycyclelandmarks.cpp + * @brief Finding sunrise, noon, sunset and midnight in an arbitrary day cycle + * + * $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$ + */ + +#include "aldaycyclelandmarks.h" + +#include +#include + +namespace ALDayCycleLandmarks +{ + +namespace +{ +/// Below this much variation in altitude across the whole cycle there is no +/// meaningful high or low point to name. Well under a degree of arc, so a +/// cycle a person would call static reads as static, and any cycle with real +/// sun movement in it clears this by orders of magnitude. +constexpr F32 FLAT_CYCLE_EPSILON = 1e-4f; + +/// Where the segment from (0, a) to (step, b) crosses zero, as an offset into +/// the segment. Only called when a and b straddle zero, so the denominator +/// cannot vanish. +F32 crossingOffset(F32 a, F32 b, F32 step) +{ + return step * (-a / (b - a)); +} +} // namespace + +Landmarks find(const altitude_sampler_t& sampler, S32 samples) +{ + Landmarks out; + + if (!sampler || samples < 2) + { + return out; + } + + const F32 step = 1.f / (F32)samples; + + std::vector altitude((size_t)samples, 0.f); + for (S32 i = 0; i < samples; ++i) + { + altitude[(size_t)i] = sampler((F32)i * step); + } + + S32 highest = 0; + S32 lowest = 0; + for (S32 i = 1; i < samples; ++i) + { + if (altitude[(size_t)i] > altitude[(size_t)highest]) + { + highest = i; + } + if (altitude[(size_t)i] < altitude[(size_t)lowest]) + { + lowest = i; + } + } + + // A cycle that does not move the sun has no moment worth jumping to, and + // saying so is better than handing back position zero four times over. + if ((altitude[(size_t)highest] - altitude[(size_t)lowest]) < FLAT_CYCLE_EPSILON) + { + return out; + } + + // Noon and midnight are the extremes, but only where the horizon makes + // them mean what they are called. A sun that stays up all cycle has a + // brightest moment and no midnight; one that never rises has neither. + if (altitude[(size_t)highest] > 0.f) + { + out.has_noon = true; + out.noon = (F32)highest * step; + } + if (altitude[(size_t)lowest] < 0.f) + { + out.has_midnight = true; + out.midnight = (F32)lowest * step; + } + + // The cycle wraps, so the last sample's neighbour is the first: a sunrise + // sitting across the seam is still a sunrise. + for (S32 i = 0; i < samples; ++i) + { + const F32 here = altitude[(size_t)i]; + const F32 next = altitude[(size_t)((i + 1) % samples)]; + + if (here < 0.f && next >= 0.f && !out.has_sunrise) + { + out.has_sunrise = true; + out.sunrise = std::fmod((F32)i * step + crossingOffset(here, next, step), 1.f); + } + else if (here >= 0.f && next < 0.f && !out.has_sunset) + { + out.has_sunset = true; + out.sunset = std::fmod((F32)i * step + crossingOffset(here, next, step), 1.f); + } + + if (out.has_sunrise && out.has_sunset) + { + break; + } + } + + return out; +} + +} // namespace ALDayCycleLandmarks diff --git a/indra/newview/aldaycyclelandmarks.h b/indra/newview/aldaycyclelandmarks.h new file mode 100644 index 0000000000..0807b53ea8 --- /dev/null +++ b/indra/newview/aldaycyclelandmarks.h @@ -0,0 +1,94 @@ +/** + * @file aldaycyclelandmarks.h + * @brief Finding sunrise, noon, sunset and midnight in an arbitrary day cycle + * + * $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$ + */ + +#ifndef AL_DAYCYCLELANDMARKS_H +#define AL_DAYCYCLELANDMARKS_H + +#include "stdtypes.h" + +#include + +/// Where the interesting moments are in a day cycle. +/// +/// A cycle position is a fraction of the whole cycle and means nothing on its +/// own: nothing in the viewer maps position to a clock. The day cycle editor +/// labels its timeline as a percentage for exactly that reason, and a region +/// can put its keyframes anywhere it likes, so "noon is 0.5" is true of some +/// day cycles and false of others. The only honest way to find noon is to ask +/// where the sun is highest, which is what this does. +/// +/// It takes a sampler rather than a day cycle so that the arithmetic can be +/// tested without a viewer around it -- the same shape `curve_editor` uses, +/// and for the same reason. The caller supplies "sun altitude at this +/// position"; whether that comes from blending a real `LLSettingsDay` or from +/// a formula in a test is not this code's business. +namespace ALDayCycleLandmarks +{ + +/// Positions in [0, 1). A landmark a cycle does not have is reported absent +/// rather than guessed at: a sun that never sets has no sunrise, and a day +/// built from one repeated frame has nothing at all. +struct Landmarks +{ + bool has_sunrise = false; + bool has_noon = false; + bool has_sunset = false; + bool has_midnight = false; + + F32 sunrise = 0.f; + F32 noon = 0.f; + F32 sunset = 0.f; + F32 midnight = 0.f; + + bool any() const { return has_sunrise || has_noon || has_sunset || has_midnight; } +}; + +/// Sun altitude at a cycle position: the vertical component of the sun's +/// direction, so +1 is overhead, 0 is exactly on the horizon and negative is +/// below it. +using altitude_sampler_t = std::function; + +/// Default sample count. 96 samples is under four minutes of resolution on a +/// cycle mapped to a 24 hour day, which is finer than the eye reads off a +/// slider, and the horizon crossings are interpolated between samples rather +/// than snapped to one, so the sunrise and sunset it finds are better than +/// the grid. +constexpr S32 DEFAULT_SAMPLES = 96; + +/// Sample the cycle and pick out its landmarks. +/// +/// Noon and midnight are the highest and lowest the sun gets. Sunrise and +/// sunset are where it crosses the horizon going up and going down, taken +/// from the first crossing of each kind so that a cycle with several is +/// answered the same way every time. +/// +/// A cycle whose altitude never varies has no landmarks -- there is no moment +/// in it to single out -- and one whose sun never sets has a noon but no +/// sunrise, sunset or midnight, because those three are defined by the +/// horizon and it never reaches it. +Landmarks find(const altitude_sampler_t& sampler, S32 samples = DEFAULT_SAMPLES); + +} // namespace ALDayCycleLandmarks + +#endif // AL_DAYCYCLELANDMARKS_H diff --git a/indra/newview/alfloaterlightbox.cpp b/indra/newview/alfloaterlightbox.cpp index bc057a03c2..5bcc1a6375 100644 --- a/indra/newview/alfloaterlightbox.cpp +++ b/indra/newview/alfloaterlightbox.cpp @@ -1,6 +1,6 @@ /** * @file alfloaterlightbox.cpp - * @brief A generic text floater for dumping info (usually debug info) + * @brief Lightbox post-processing control floater * * Copyright (C) Rye Mutt * @@ -31,71 +31,292 @@ #include "llviewerprecompiledheaders.h" #include "alfloaterlightbox.h" -//#include "alrenderutils.h" -#include "llviewercontrol.h" -#include "llspinctrl.h" -#include "llsliderctrl.h" -#include "lltextbox.h" +#include "llaccordionctrltab.h" #include "llcombobox.h" +#include "llfloaterreg.h" +#include "alcurveeditorctrl.h" +#include "alcurvemodel.h" +#include "altoolscenepicker.h" +#include "alwhitebalancesolver.h" +#include "llagent.h" +#include "llenvironment.h" +#include "llfile.h" +#include "llnotificationsutil.h" +#include "llpanel.h" +#include "llpresetsmanager.h" +#include "llsettingsvo.h" +#include "llspinctrl.h" +#include "lltimer.h" +#include "lltoolmgr.h" +#include "llviewercontrol.h" +#include "pipeline.h" +#include "rlvactions.h" + +#include +#include +#include +#include + +namespace +{ +/// Everything recorded while this lives becomes a single undo step. +struct ScopedHistoryGroup +{ + explicit ScopedHistoryGroup(ALGradeHistory& history) : mHistory(history) { mHistory.beginGroup(); } + ~ScopedHistoryGroup() { mHistory.endGroup(); } + ScopedHistoryGroup(const ScopedHistoryGroup&) = delete; + ScopedHistoryGroup& operator=(const ScopedHistoryGroup&) = delete; + + ALGradeHistory& mHistory; +}; + +/// Holds a flag true for a scope. Never nested here -- if it ever is, this +/// needs to be a counter rather than a bool. +struct ScopedTrue +{ + explicit ScopedTrue(bool& flag) : mFlag(flag) { mFlag = true; } + ~ScopedTrue() { mFlag = false; } + ScopedTrue(const ScopedTrue&) = delete; + ScopedTrue& operator=(const ScopedTrue&) = delete; + + bool& mFlag; +}; + +// Vector-valued rows follow the widget naming contract "vec3__<0|1|2>"; +// setting names never contain '_', so the parse is unambiguous. +bool parseVec3WidgetName(const std::string& name, std::string& setting, S32& component) +{ + static const std::string prefix = "vec3_"; + if (name.size() <= prefix.size() || name.compare(0, prefix.size(), prefix) != 0) + { + return false; + } + size_t sep = name.rfind('_'); + if (sep <= prefix.size() || sep + 2 != name.size()) + { + return false; + } + S32 comp = name[sep + 1] - '0'; + if (comp < 0 || comp > 2) + { + return false; + } + setting = name.substr(prefix.size(), sep - prefix.size()); + component = comp; + return true; +} + +// Any LLUICtrl will do; the name is the whole contract. Descending into +// composites is safe because their internal children are named for their role +// ("Slider", "value") and cannot parse as vec3__. +void collectVec3Spinners(LLView* viewp, std::map>& rows) +{ + for (LLView* childp : *viewp->getChildList()) + { + if (LLUICtrl* ctrlp = dynamic_cast(childp)) + { + std::string setting; + S32 component = 0; + if (parseVec3WidgetName(ctrlp->getName(), setting, component)) + { + rows[setting][component] = ctrlp; + } + } + collectVec3Spinners(childp, rows); + } +} + +void collectBoundControls(LLView* viewp, std::set& keys) +{ + for (LLView* childp : *viewp->getChildList()) + { + if (LLUICtrl* ctrlp = dynamic_cast(childp)) + { + if (LLControlVariable* controlp = ctrlp->getControlVariable()) + { + // Only reset controls owned by gSavedSettings; enabled/visibility + // bindings live in separate slots and are not touched here. + if (gSavedSettings.getControl(controlp->getName()) == controlp) + { + keys.insert(controlp->getName()); + } + } + std::string setting; + S32 component = 0; + if (parseVec3WidgetName(ctrlp->getName(), setting, component)) + { + keys.insert(setting); + } + } + collectBoundControls(childp, keys); + } +} +} // namespace ALFloaterLightBox::ALFloaterLightBox(const LLSD& key) : LLFloater(key) { mCommitCallbackRegistrar.add("LightBox.ResetControlDefault", std::bind(&ALFloaterLightBox::onClickResetControlDefault, this, std::placeholders::_2)); - mCommitCallbackRegistrar.add("LightBox.ResetGroupDefault", std::bind(&ALFloaterLightBox::onClickResetGroupDefault, this, std::placeholders::_2)); + mCommitCallbackRegistrar.add("LightBox.ResetSection", std::bind(&ALFloaterLightBox::onClickResetSection, this, std::placeholders::_2)); + mCommitCallbackRegistrar.add("LightBox.ToggleSection", std::bind(&ALFloaterLightBox::onToggleSection, this, std::placeholders::_1, std::placeholders::_2)); + mCommitCallbackRegistrar.add("LightBox.ReferenceGrab", std::bind(&ALFloaterLightBox::onClickReferenceGrab, this)); + mCommitCallbackRegistrar.add("LightBox.ReferenceClear", std::bind(&ALFloaterLightBox::onClickReferenceClear, this)); + mCommitCallbackRegistrar.add("LightBox.ToggleDayFreeze", std::bind(&ALFloaterLightBox::onToggleDayFreeze, this, std::placeholders::_1)); + mCommitCallbackRegistrar.add("LightBox.CommitDayTime", std::bind(&ALFloaterLightBox::onCommitDayTime, this, std::placeholders::_1)); + mCommitCallbackRegistrar.add("LightBox.DayPreset", std::bind(&ALFloaterLightBox::onClickDayPreset, this, std::placeholders::_2)); + mCommitCallbackRegistrar.add("LightBox.ToggleCloudScroll", std::bind(&ALFloaterLightBox::onToggleCloudScroll, this, std::placeholders::_1)); + mCommitCallbackRegistrar.add("LightBox.RestoreEnvironment", std::bind(&ALFloaterLightBox::onClickRestoreEnvironment, this)); + // Lambdas rather than bind: applyHistory answers whether it did anything, + // and a commit callback returns nothing, so say plainly that the answer is + // not wanted here. The buttons are greyed when there is nothing to do. + mCommitCallbackRegistrar.add("LightBox.Undo", [this](LLUICtrl*, const LLSD&) { applyHistory(false); }); + mCommitCallbackRegistrar.add("LightBox.Redo", [this](LLUICtrl*, const LLSD&) { applyHistory(true); }); + mCommitCallbackRegistrar.add("LightBox.CommitVec3", std::bind(&ALFloaterLightBox::onCommitVec3, this, std::placeholders::_1)); + mCommitCallbackRegistrar.add("LightBox.CommitToneCurve", std::bind(&ALFloaterLightBox::onCommitToneCurve, this)); + mCommitCallbackRegistrar.add("LightBox.RefreshToneCurve", std::bind(&ALFloaterLightBox::refreshToneCurve, this)); + 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.LookSelected", std::bind(&ALFloaterLightBox::onLookSelected, this)); + mCommitCallbackRegistrar.add("LightBox.LookSave", std::bind(&ALFloaterLightBox::onClickLookSave, this)); + mCommitCallbackRegistrar.add("LightBox.LookSaveAs", std::bind(&ALFloaterLightBox::onClickLookSaveAs, this)); + mCommitCallbackRegistrar.add("LightBox.LookDelete", std::bind(&ALFloaterLightBox::onClickLookDelete, this)); + mCommitCallbackRegistrar.add("LightBox.LookRevert", std::bind(&ALFloaterLightBox::onClickLookRevert, this)); } ALFloaterLightBox::~ALFloaterLightBox() { - mTonemapConnection.disconnect(); - mCASConnection.disconnect(); + // The handle in the pick callback already makes a late sample harmless, but + // an armed picker outliving its floater would leave the user holding an + // eyedropper cursor that has nothing left to tell. Put the previous tool + // back instead. + if (LLToolMgr::instanceExists() && + LLToolMgr::getInstance()->getCurrentTool() == ALToolScenePicker::getInstance()) + { + LLToolMgr::getInstance()->clearTransientTool(); + } + + // The bypass mask lives in the pipeline and the checkboxes that drive it + // live here, so closing the floater with one ticked would leave a section + // permanently suppressed with nothing left on screen to say so, and no + // setting to inspect either. The comparison ends when the tool does. + LLPipeline::sGradeBypassMask = 0; + + // Same rule for the reference still, with a second reason: it is a + // full-resolution target, so leaving one behind holds real memory for a + // comparison nobody can see or turn off any more. + gSavedSettings.setS32("RenderReferenceWipeMode", 0); + gPipeline.clearReferenceStill(); } bool ALFloaterLightBox::postBuild() { populateLUTCombo(); - updateTonemapper(); - updateCAS(); - mTonemapConnection = gSavedSettings.getControl("AlchemyRenderTonemapType")->getSignal()->connect([&](LLControlVariable* control, const LLSD&, const LLSD&) { updateTonemapper(); }); - //mCASConnection = gSavedSettings.getControl("RenderSharpenMethod")->getSignal()->connect([&](LLControlVariable* control, const LLSD&, const LLSD&) { updateCAS(); }); + mTonemapConnection = gSavedSettings.getControl("AlchemyRenderTonemapType")->getSignal()->connect( + [this](LLControlVariable*, const LLSD&, const LLSD&) { updateTonemapperRows(); }); + updateTonemapperRows(); - return LLFloater::postBuild(); -} + mLooksCombo = getChild("looks_combo"); + mLooksListConnection = LLPresetsManager::instance().setPresetListChangeLooksCallback( + std::bind(&ALFloaterLightBox::refreshLooksBar, this)); + mLooksActiveConnection = gSavedSettings.getControl("PresetLooksActive")->getSignal()->connect( + [this](LLControlVariable*, const LLSD&, const LLSD&) { refreshLooksBar(); }); + refreshLooksBar(); -void ALFloaterLightBox::draw() -{ - LLFloater::draw(); + mUndoButton = findChild("look_undo"); + mRedoButton = findChild("look_redo"); + mReferenceClear = findChild("reference_clear"); + mReferenceMode = findChild("reference_mode"); + mReferencePosition = findChild("reference_position"); + + mDayFreeze = findChild("day_freeze"); + mDayTime = findChild("day_time"); + mCloudScroll = findChild("day_pause_clouds"); + mRestoreEnvironment = findChild("day_restore_environment"); + mDayPresets = { findChild("day_sunrise"), findChild("day_noon"), + findChild("day_sunset"), findChild("day_midnight") }; + + // Undo watches exactly the settings a Look carries. Sharing that list is + // the point: a control worth saving is a control worth undoing, so a new + // grading setting joins both at once instead of one and not the other. + // + // The signal hands over the old value as well as the new one, so there is + // no shadow copy to keep in step -- and llcontrol only fires it when the + // value really changed (setValue and resetToDefault both gate on + // llsd_compare), so a commit that rewrites the same value cannot put a + // do-nothing step on the stack. + std::vector looks_controls; + LLPresetsManager::instance().getLooksControlNames(looks_controls); + for (const std::string& setting : looks_controls) + { + LLControlVariable* controlp = gSavedSettings.getControl(setting); + if (!controlp) + { + // LLPresetsManager already warns loudly about a whitelist name + // that has stopped existing; no need to say it twice. + continue; + } + mHistoryConnections.emplace_back(controlp->getSignal()->connect( + [this, setting](LLControlVariable*, const LLSD& new_value, const LLSD& old_value) + { onGradeSettingChanged(setting, old_value, new_value); })); + } + + collectVec3Spinners(this, mVec3Rows); + for (const auto& row : mVec3Rows) + { + const std::string& setting = row.first; + LLControlVariable* controlp = gSavedSettings.getControl(setting); + if (!controlp) + { + LL_WARNS() << "Vec3 row bound to unknown setting: " << setting << LL_ENDL; + continue; + } + mVec3Connections.emplace_back(controlp->getSignal()->connect( + [this, setting](LLControlVariable*, const LLSD&, const LLSD&) { refreshVec3Row(setting); })); + refreshVec3Row(setting); + } + + setupToneCurve(); + setupSplitToneGraph(); + + return LLFloater::postBuild(); } void ALFloaterLightBox::populateLUTCombo() { LLComboBox* lut_combo = getChild("colorlut_combo"); - const std::string& user_luts = gDirUtilp->getExpandedFilename(LL_PATH_USER_SETTINGS, "colorlut"); - std::error_code ec; - std::filesystem::path user_luts_path = fsyspath(user_luts); - if(std::filesystem::is_directory(user_luts_path, ec)) + // 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" }; + + // 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) { - if(ec) + 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) { - LL_WARNS() << "Error checking user LUTs directory: " << ec.message() << LL_ENDL; - return; + return found; } - if(!std::filesystem::is_empty(user_luts_path, ec) && !ec) - { - if(ec) - { - LL_WARNS() << "Error checking contents of user LUTs directory: " << ec.message() << LL_ENDL; - return; - } - lut_combo->addSeparator(); - } - for (std::filesystem::directory_iterator lut(user_luts_path, ec); lut != std::filesystem::directory_iterator(); ++lut) + + // increment(ec), not ++: the throwing increment would carry a + // transient filesystem error out through postBuild. On failure it + // 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)) { - if(ec) + std::error_code entry_ec; + if (!lut->is_regular_file(entry_ec) || entry_ec) { - LL_WARNS() << "Error reading user LUT file: " << ec.message() << LL_ENDL; continue; } #if LL_WINDOWS @@ -105,11 +326,51 @@ void ALFloaterLightBox::populateLUTCombo() std::string lut_stem = lut->path().stem().native(); std::string lut_filename = lut->path().filename().native(); #endif - lut_combo->add(lut_stem, lut_filename); + const std::string exten = gDirUtilp->getExtension(lut_filename); + if (std::find(std::begin(LUT_EXTENSIONS), std::end(LUT_EXTENSIONS), exten) == std::end(LUT_EXTENSIONS)) + { + continue; + } + found.emplace_back(std::move(lut_stem), std::move(lut_filename)); + } + if (ec) + { + LL_WARNS() << "Error reading LUT directory " << dir_name << ": " << ec.message() << LL_ENDL; + } + return found; + }; + + // Bundled LUTs first, then user LUTs 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"))) + { + lut_combo->add(lut.first, lut.second); + } + + const auto user_luts = collect_luts_from(gDirUtilp->getExpandedFilename(LL_PATH_USER_SETTINGS, "colorlut")); + if (!user_luts.empty()) + { + lut_combo->addSeparator(); + for (const auto& lut : user_luts) + { + lut_combo->add(lut.first, lut.second); } - lut_combo->selectByValue(gSavedSettings.getString("RenderColorGradeLUT")); - lut_combo->resetDirty(); } + + lut_combo->selectByValue(gSavedSettings.getString("RenderColorGradeLUT")); + lut_combo->resetDirty(); +} + +void ALFloaterLightBox::onClickOpenLUTFolder() +{ + // 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"); + LLFile::mkdir(dir); + gDirUtilp->openDir(dir); } void ALFloaterLightBox::onClickResetControlDefault(const LLSD& userdata) @@ -122,537 +383,1095 @@ void ALFloaterLightBox::onClickResetControlDefault(const LLSD& userdata) } } -void ALFloaterLightBox::onClickResetGroupDefault(const LLSD& userdata) +void ALFloaterLightBox::onClickResetSection(const LLSD& userdata) { - const std::string& setting_group = userdata.asString(); - if (setting_group == "sharpen") + const std::string& section = userdata.asString(); + if (section.empty()) + { + return; + } + + std::set keys; + if (LLPanel* panelp = findChild(section)) { - LLControlVariable* controlp = gSavedSettings.getControl("RenderSharpenMethod"); - if (controlp) + collectBoundControls(panelp, keys); + } + if (LLPanel* advp = findChild(section + "_adv")) + { + collectBoundControls(advp, keys); + } + + // A section's own header can carry a bound checkbox -- Color Grading's + // master switch does -- and that is part of the section however it is + // drawn. It is not in the panel, so nothing above would find it. Walk the + // control rather than reading it directly: LLCheckBoxCtrl hands + // control_name down to its button, so the binding is on the child. + if (auto* tabp = findChild("atab_" + section)) + { + if (LLCheckBoxCtrl* checkp = tabp->getHeaderCheckBox()) { - controlp->resetToDefault(true); + collectBoundControls(checkp, keys); } - controlp = gSavedSettings.getControl("RenderSharpenCASSharpness"); - if (controlp) + } + + // One thing the user did, however many controls it moves: undoing a Reset + // All eleven times would be absurd. + ScopedHistoryGroup group(mHistory); + for (const std::string& key : keys) + { + if (LLControlVariable* controlp = gSavedSettings.getControl(key)) { controlp->resetToDefault(true); } - controlp = gSavedSettings.getControl("RenderSharpenDLSSharpness"); - if (controlp) + } +} + +void ALFloaterLightBox::onToggleSection(LLUICtrl* ctrl, const LLSD& userdata) +{ + // Section id to bit. The grouping matches Reset All's, which walks + // sec_ and sec__adv together, so a section with a long tail in an + // Advanced sibling is one switch and one idea of what a section is. + static const std::map bypass_bits = { + { "basic", LLPipeline::GRADE_BYPASS_BASIC }, + { "primaries", LLPipeline::GRADE_BYPASS_PRIMARIES }, + { "split", LLPipeline::GRADE_BYPASS_SPLIT }, + { "lut", LLPipeline::GRADE_BYPASS_LUT }, + { "curve", LLPipeline::GRADE_BYPASS_CURVE }, + }; + + const auto found = bypass_bits.find(userdata.asString()); + if (!ctrl || found == bypass_bits.end()) + { + return; + } + + // Ticked is the section switched on, so the bit -- which suppresses -- + // is set when the box is clear. + // + // Nothing is written to gSavedSettings here, deliberately: every grading + // control is on the Looks whitelist, so a comparison built out of one + // would mark the active Look dirty and could then be saved mid-comparison. + // That is also why the checkboxes have no control_name: they are worth + // exactly one viewing session, and the floater is destroyed when it + // closes, which is what puts them all back on. + if (ctrl->getValue().asBoolean()) + { + LLPipeline::sGradeBypassMask &= ~found->second; + } + else + { + LLPipeline::sGradeBypassMask |= found->second; + } +} + +void ALFloaterLightBox::onClickReferenceGrab() +{ + // The grab is serviced on the next frame's blit, so what gets frozen is + // what the user is looking at as they click, not a frame already gone. + gPipeline.requestReferenceStill(); + + // Switching the wipe on is the feedback that the grab happened: a Grab + // button that visibly does nothing reads as broken, and a still nobody is + // looking at has no reason to exist yet. + if (gSavedSettings.getS32("RenderReferenceWipeMode") == 0) + { + gSavedSettings.setS32("RenderReferenceWipeMode", 1); + } + + // The still does not exist until the frame renders, so the row cannot be + // refreshed to its final state here; the draw loop picks it up. + refreshReferenceRow(); +} + +void ALFloaterLightBox::onClickReferenceClear() +{ + gSavedSettings.setS32("RenderReferenceWipeMode", 0); + gPipeline.clearReferenceStill(); + refreshReferenceRow(); +} + +void ALFloaterLightBox::refreshReferenceRow() +{ + // Whether a still exists is render state, not a setting, so nothing + // signals its arrival -- a grab is only serviced when the frame renders, + // and a resize drops the still without asking. Hence the poll from draw(). + const bool have_still = gPipeline.hasReferenceStill(); + const S32 mode = gSavedSettings.getS32("RenderReferenceWipeMode"); + + // Only the wipe has a movable seam; side by side is always centred. + const S32 state = (have_still ? 1 : 0) | ((have_still && mode == 1) ? 2 : 0); + if (state == mReferenceRowState) + { + return; + } + mReferenceRowState = state; + + if (mReferenceClear) + { + mReferenceClear->setEnabled(have_still); + } + if (mReferenceMode) + { + mReferenceMode->setEnabled(have_still); + } + if (mReferencePosition) + { + mReferencePosition->setEnabled((state & 2) != 0); + } +} + +void ALFloaterLightBox::refreshHistoryButtons() +{ + // Polled for the same reason the reference row is: the stack moves on + // every commit, on every undo, and on a Look being applied, and hanging a + // refresh off each of those is more places to forget than this costs. + const S32 state = (mHistory.canUndo() ? 1 : 0) | (mHistory.canRedo() ? 2 : 0); + if (state == mHistoryButtonState) + { + return; + } + mHistoryButtonState = state; + + if (mUndoButton) + { + mUndoButton->setEnabled((state & 1) != 0); + } + if (mRedoButton) + { + mRedoButton->setEnabled((state & 2) != 0); + } +} + +void ALFloaterLightBox::draw() +{ + refreshReferenceRow(); + refreshHistoryButtons(); + refreshDayCycleRow(); + LLFloater::draw(); +} + +std::shared_ptr ALFloaterLightBox::getScrubbableDay() const +{ + // The order the viewer itself resolves environments in. ENV_LOCAL leads + // because a day the user applied is the one they would expect to scrub; + // while frozen it holds a fixed sky and has no day, so the search falls + // through to whatever the parcel or region is running. That fall-through + // is also what lets scrubbing keep working after the floater has been + // closed and reopened, since nothing about it is remembered here. + static const LLEnvironment::EnvSelection_t sources[] = { + LLEnvironment::ENV_LOCAL, LLEnvironment::ENV_PUSH, + LLEnvironment::ENV_PARCEL, LLEnvironment::ENV_REGION }; + + for (LLEnvironment::EnvSelection_t env : sources) + { + if (LLSettingsDay::ptr_t day = LLEnvironment::instance().getEnvironmentDay(env)) + { + return day; + } + } + return {}; +} + +bool ALFloaterLightBox::isSkyFrozen() const +{ + return (bool)LLEnvironment::instance().getEnvironmentFixedSky(LLEnvironment::ENV_LOCAL); +} + +void ALFloaterLightBox::applyDayPosition(F32 position) +{ + LLSettingsDay::ptr_t day = getScrubbableDay(); + if (!day) + { + return; + } + + mDayPosition = llclamp(position, 0.f, 1.f); + + // A day cycle carries up to four sky tracks and which one you see depends + // on how high you are, so sample the one the agent is actually in. Track 0 + // is water, always, and it gets sampled too: the day cycle editor blends + // both and a frozen sky over a moving sea is not frozen. + const S32 track = LLEnvironment::instance().calculateSkyTrackForAltitude( + gAgent.getPositionAgent().mV[VZ]); + + LLSettingsSky::ptr_t sky = LLSettingsVOSky::buildDefaultSky(); + LLSettingsWater::ptr_t water = LLSettingsVOWater::buildDefaultWater(); + + // make_shared rather than a temporary: LLSettingsBlender is held by shared + // pointer internally, which is how the day cycle editor and @setenv_daytime + // both spell this. + std::make_shared(sky, day, track)->setPosition(mDayPosition); + std::make_shared(water, day, (S32)LLSettingsDay::TRACK_WATER) + ->setPosition(mDayPosition); + + LLEnvironment::instance().setEnvironment(LLEnvironment::ENV_LOCAL, sky, water); + LLEnvironment::instance().setSelectedEnvironment(LLEnvironment::ENV_LOCAL, LLEnvironment::TRANSITION_INSTANT); + LLEnvironment::instance().updateEnvironment(LLEnvironment::TRANSITION_INSTANT); +} + +void ALFloaterLightBox::freezeSkyAt(F32 position) +{ + if (!mDayFreezeIsOurs) + { + // Whatever is here now is about to be covered up, and unticking Freeze + // has to give it back. Without this, freezing over a Personal Lighting + // sky and unfreezing again would quietly drop the user back to the + // region default and lose their sky. + LLEnvironment& env = LLEnvironment::instance(); + mPreFreezeDay = env.getEnvironmentDay(LLEnvironment::ENV_LOCAL); + if (mPreFreezeDay) { - controlp->resetToDefault(true); + mPreFreezeDayLength = env.getEnvironmentDayLength(LLEnvironment::ENV_LOCAL).value(); + mPreFreezeDayOffset = env.getEnvironmentDayOffset(LLEnvironment::ENV_LOCAL).value(); + } + const LLEnvironment::fixedEnvironment_t fixed = env.getEnvironmentFixed(LLEnvironment::ENV_LOCAL); + mPreFreezeSky = fixed.first; + mPreFreezeWater = fixed.second; + mDayFreezeIsOurs = true; + } + + applyDayPosition(position); +} + +void ALFloaterLightBox::thawSky() +{ + LLEnvironment& env = LLEnvironment::instance(); + + // Every reflection probe in the scene was lit by the sky being replaced. + // The World menu's own revert does this for the same reason; without it + // the first frames after a thaw carry the light of the sky just left. + gPipeline.mReflectionMapManager.reset(); + + if (mDayFreezeIsOurs && mPreFreezeDay) + { + env.setEnvironment(LLEnvironment::ENV_LOCAL, mPreFreezeDay, + LLSettingsDay::Seconds(mPreFreezeDayLength), + LLSettingsDay::Seconds(mPreFreezeDayOffset)); + } + else if (mDayFreezeIsOurs && (mPreFreezeSky || mPreFreezeWater)) + { + env.setEnvironment(LLEnvironment::ENV_LOCAL, mPreFreezeSky, mPreFreezeWater); + } + else + { + // Nothing to give back, so fall through to the parcel or region. + env.clearEnvironment(LLEnvironment::ENV_LOCAL); + } + + env.setSelectedEnvironment(LLEnvironment::ENV_LOCAL, LLEnvironment::TRANSITION_INSTANT); + env.updateEnvironment(LLEnvironment::TRANSITION_INSTANT); + + mPreFreezeDay.reset(); + mPreFreezeSky.reset(); + mPreFreezeWater.reset(); + mDayFreezeIsOurs = false; +} + +void ALFloaterLightBox::refreshDayLandmarks() +{ + LLSettingsDay::ptr_t day = getScrubbableDay(); + if (day == mLandmarkDay) + { + return; + } + + mLandmarkDay = day; + mDayLandmarks = ALDayCycleLandmarks::Landmarks(); + if (!day) + { + return; + } + + // One scratch sky, re-blended at each sample. getSunDirection's Z is the + // sun's height above the horizon, which is the only thing that says where + // noon is in a cycle whose keyframes could be anywhere. + const S32 track = LLEnvironment::instance().calculateSkyTrackForAltitude( + gAgent.getPositionAgent().mV[VZ]); + LLSettingsSky::ptr_t scratch = LLSettingsVOSky::buildDefaultSky(); + auto blender = std::make_shared(scratch, day, track); + + mDayLandmarks = ALDayCycleLandmarks::find( + [&blender, &scratch](F32 position) + { + blender->setPosition(position); + return scratch->getSunDirection().mV[VZ]; + }); +} + +void ALFloaterLightBox::refreshDayCycleRow() +{ + if (!mDayFreeze) + { + return; // the XUI is free to drop the row + } + + const bool can_change = RlvActions::canChangeEnvironment(); + const bool frozen = isSkyFrozen(); + const bool has_day = (bool)getScrubbableDay(); + + // While the sky is running, the slider shows where it actually is, so + // ticking Freeze holds the moment being looked at rather than jumping. + if (!frozen) + { + const F32 live = LLEnvironment::instance().getProgress(); + if (live >= 0.f) + { + mDayPosition = live; + if (mDayTime) + { + mDayTime->setValue(mDayPosition); + } } - controlp = gSavedSettings.getControl("RenderSharpenDLSDenoise"); - if (controlp) + // A sky frozen by something else, then cleared by it, leaves us + // holding a restore point for an environment that is already back. + mDayFreezeIsOurs = false; + } + + // Clouds are polled alongside because the World menu can pause them too, + // and a checkbox that disagrees with the sky is worse than none. + const bool clouds_paused = LLEnvironment::instance().isCloudScrollPaused(); + + const S32 state = (can_change ? 1 : 0) | (frozen ? 2 : 0) | (has_day ? 4 : 0) + | (clouds_paused ? 8 : 0); + if (state == mDayCycleRowState) + { + return; + } + mDayCycleRowState = state; + + if (mCloudScroll) + { + mCloudScroll->setValue(clouds_paused); + } + + // @setenv is enforced inside LLEnvironment, not here, so without this the + // controls would look live and do nothing at all under a restriction. + mDayFreeze->setEnabled(can_change && has_day); + mDayFreeze->setValue(frozen); + if (mDayTime) + { + mDayTime->setEnabled(can_change && frozen && has_day); + } + if (mRestoreEnvironment) + { + mRestoreEnvironment->setEnabled(can_change && frozen); + } + + // Presets cost a search, so only look when the row is actually usable. + if (can_change && has_day) + { + refreshDayLandmarks(); + } + const bool present[4] = { mDayLandmarks.has_sunrise, mDayLandmarks.has_noon, + mDayLandmarks.has_sunset, mDayLandmarks.has_midnight }; + for (size_t i = 0; i < mDayPresets.size(); ++i) + { + if (mDayPresets[i]) { - controlp->resetToDefault(true); + mDayPresets[i]->setEnabled(can_change && has_day && present[i]); + } + } +} + +void ALFloaterLightBox::onToggleDayFreeze(LLUICtrl* ctrl) +{ + if (!ctrl) + { + return; + } + + if (ctrl->getValue().asBoolean()) + { + freezeSkyAt(mDayPosition); + } + else + { + thawSky(); + } + mDayCycleRowState = -1; +} + +void ALFloaterLightBox::onCommitDayTime(LLUICtrl* ctrl) +{ + if (ctrl) + { + // Through freezeSkyAt, not applyDayPosition, even though the slider is + // only live while the sky is already frozen: "frozen" can also mean a + // fixed sky somebody else installed -- Personal Lighting, say -- which + // we have not captured. The first scrub over one of those is what + // covers it up, so it is the moment to remember it, or unticking + // Freeze drops the user to the region default instead of giving their + // sky back. Once the freeze is ours the capture is skipped and this is + // applyDayPosition, so a drag costs nothing extra per tick. + freezeSkyAt((F32)ctrl->getValue().asReal()); + } +} + +void ALFloaterLightBox::onClickDayPreset(const LLSD& userdata) +{ + refreshDayLandmarks(); + + const std::string& which = userdata.asString(); + const ALDayCycleLandmarks::Landmarks& marks = mDayLandmarks; + + F32 position = 0.f; + if (which == "sunrise" && marks.has_sunrise) { position = marks.sunrise; } + else if (which == "noon" && marks.has_noon) { position = marks.noon; } + else if (which == "sunset" && marks.has_sunset) { position = marks.sunset; } + else if (which == "midnight" && marks.has_midnight) { position = marks.midnight; } + else { return; } + + // A preset moves the slider and freezes there; it does not install a + // canned sky, so the region's own idea of noon is what you get and you can + // keep scrubbing from it. + freezeSkyAt(position); + if (mDayTime) + { + mDayTime->setValue(mDayPosition); + } + mDayCycleRowState = -1; +} + +void ALFloaterLightBox::onToggleCloudScroll(LLUICtrl* ctrl) +{ + if (!ctrl) + { + return; + } + + // Clouds drift on their own timer, entirely apart from the day cycle, so a + // frozen sky with this off still has weather moving through it. + if (ctrl->getValue().asBoolean()) + { + LLEnvironment::instance().pauseCloudScroll(); + } + else + { + LLEnvironment::instance().resumeCloudScroll(); + } +} + +void ALFloaterLightBox::onClickRestoreEnvironment() +{ + // Unconditional, unlike unticking Freeze: this is the way back to the + // parcel or region whatever we happen to be holding, including a sky some + // other floater installed. + mDayFreezeIsOurs = false; + mPreFreezeDay.reset(); + mPreFreezeSky.reset(); + mPreFreezeWater.reset(); + thawSky(); + mDayCycleRowState = -1; +} + +void ALFloaterLightBox::onGradeSettingChanged(const std::string& name, const LLSD& before, const LLSD& after) +{ + if (mApplyingHistory) + { + // Our own write, coming back round. Recording it would append the undo + // to the stack it was taken from, and Ctrl+Z would toggle forever + // between two values instead of walking backwards. + return; + } + + // A monotonic clock is all the history wants: it compares two of these to + // decide whether one drag is still in progress, and never reads the value + // on its own. + mHistory.record(name, before, after, (F32)LLTimer::getElapsedSeconds().value()); +} + +bool ALFloaterLightBox::applyHistory(bool redo_direction) +{ + const ALGradeHistory::Transaction* stepp = redo_direction ? mHistory.redo() : mHistory.undo(); + if (!stepp) + { + return false; + } + + // Copied, not followed. The pointer is into the history's own stack, and + // while the guard below means nothing can record during the writes -- and + // so nothing can resize that stack -- relying on it would make this + // function's safety a property of code somewhere else. A transaction is a + // handful of LLSD. + const ALGradeHistory::Transaction step = *stepp; + + ScopedTrue applying(mApplyingHistory); + for (const ALGradeHistory::Change& change : step) + { + if (LLControlVariable* controlp = gSavedSettings.getControl(change.mName)) + { + controlp->setValue(redo_direction ? change.mAfter : change.mBefore); + } + } + + // Deliberately no attempt to restore which Look was active. Undo restores + // values; the Look stays dirty, exactly as it would if the user had typed + // the old numbers back in. See ALGradeHistory's header. + return true; +} + +bool ALFloaterLightBox::handleKeyHere(KEY key, MASK mask) +{ + // Reached only after the focus chain has declined the key, so a text field + // in the middle of an edit keeps Ctrl+Z for its own undo. + if (key == 'Z' && mask == MASK_CONTROL) + { + applyHistory(false); + return true; + } + + // Both spellings: Ctrl+Y is the Windows convention and Ctrl+Shift+Z the one + // every grading application uses. + if ((key == 'Y' && mask == MASK_CONTROL) || + (key == 'Z' && mask == (MASK_CONTROL | MASK_SHIFT))) + { + applyHistory(true); + return true; + } + + return LLFloater::handleKeyHere(key, mask); +} + +void ALFloaterLightBox::onCommitVec3(LLUICtrl* ctrl) +{ + if (mVec3Updating || !ctrl) + { + return; + } + + std::string setting; + S32 component = 0; + if (!parseVec3WidgetName(ctrl->getName(), setting, component)) + { + return; + } + + LLControlVariable* controlp = gSavedSettings.getControl(setting); + if (!controlp) + { + return; + } + + // Rebuild the full component array so a single spinner commit writes back + // one component without disturbing the others; works for VEC3 and COL3. + const LLSD current = controlp->getValue(); + LLSD updated = LLSD::emptyArray(); + for (S32 i = 0; i < 3; ++i) + { + updated.append(LLSD::Real(current[i].asReal())); + } + updated[component] = LLSD::Real(ctrl->getValue().asReal()); + controlp->set(updated); +} + +namespace +{ +// The three settings the tone curve graph edits, and the order the handles +// are built in. Kept together so the graph and the spinner rows below it can +// never disagree about which key is which. +const char* const TONE_CURVE_TOE = "RenderColorGradeCurveToe"; +const char* const TONE_CURVE_SHOULDER = "RenderColorGradeCurveShoulder"; +const char* const TONE_CURVE_STRENGTH = "RenderColorGradeCurveStrength"; + +// The split-toning settings the band graph reads. The tints colour the bands, +// the amounts fade them, and the balance is the only one the graph writes. +const char* const SPLIT_TONE_SHADOW = "RenderSplitToneShadowTint"; +const char* const SPLIT_TONE_MIDTONE = "RenderSplitToneMidtoneTint"; +const char* const SPLIT_TONE_HIGHLIGHT = "RenderSplitToneHighlightTint"; +const char* const SPLIT_TONE_AMOUNT = "RenderSplitToneAmount"; +const char* const SPLIT_TONE_MIDTONE_AMOUNT = "RenderSplitToneMidtoneAmount"; +const char* const SPLIT_TONE_BALANCE = "RenderSplitToneBalance"; + +LLColor3 getColor3(const char* key) +{ + return gSavedSettings.getColor3(key); +} + +// Write one component, or all three when channel is negative. Rebuilds the +// whole array the way onCommitVec3 does, so an untouched component keeps its +// value rather than being re-derived from a rounded read-back. +void setColor3Component(const char* key, S32 channel, F32 value) +{ + LLControlVariable* controlp = gSavedSettings.getControl(key); + if (!controlp) + { + return; + } + const LLSD current = controlp->getValue(); + LLSD updated = LLSD::emptyArray(); + for (S32 i = 0; i < 3; ++i) + { + const bool touched = (channel < 0) || (channel == i); + updated.append(LLSD::Real(touched ? value : current[i].asReal())); + } + controlp->set(updated); +} + +/// Where the curve departs furthest from the identity. That is the most +/// sensitive place to read strength back from a dragged handle -- solving +/// k = (y - x) / (s - x) anywhere the two are close would amplify a pixel of +/// pointer movement into a wild swing -- and it is also where the eye reads +/// the curve's effect, so it is where the handle belongs. +F32 findMaxDeviation(F32 toe, F32 shoulder) +{ + constexpr S32 SAMPLES = 64; + F32 best_x = 0.5f; + F32 best_gap = -1.f; + for (S32 i = 1; i < SAMPLES; ++i) + { + const F32 x = (F32)i / (F32)SAMPLES; + // The pure curve at full strength: the deviation the blend scales. + const F32 gap = fabsf(ALCurveModel::smoothstep(x, toe, shoulder, 1.f) - x); + if (gap > best_gap) + { + best_gap = gap; + best_x = x; + } + } + return best_x; +} +} // namespace + +S32 ALFloaterLightBox::getToneCurveChannel() const +{ + return mToneCurveChannel ? mToneCurveChannel->getValue().asInteger() : -1; +} + +void ALFloaterLightBox::setupToneCurve() +{ + mToneCurve = findChild("tone_curve_graph"); + if (!mToneCurve) + { + return; + } + mToneCurveChannel = findChild("tone_curve_channel"); + if (mToneCurveChannel) + { + // Start linked. Without an explicit selection an unselected combo + // reads back as 0, which would silently mean "red only". + mToneCurveChannel->selectByValue(LLSD(-1)); + } + + for (const char* key : { TONE_CURVE_TOE, TONE_CURVE_SHOULDER, TONE_CURVE_STRENGTH }) + { + if (LLControlVariable* controlp = gSavedSettings.getControl(key)) + { + mToneCurveConnections.emplace_back(controlp->getSignal()->connect( + [this](LLControlVariable*, const LLSD&, const LLSD&) { refreshToneCurve(); })); + } + } + + refreshToneCurve(); +} + +void ALFloaterLightBox::refreshToneCurve() +{ + if (!mToneCurve || mToneCurveUpdating) + { + return; + } + + const LLColor3 toe = getColor3(TONE_CURVE_TOE); + const LLColor3 shoulder = getColor3(TONE_CURVE_SHOULDER); + const LLColor3 strength = getColor3(TONE_CURVE_STRENGTH); + + // Linked edits all three at once and shows one curve; a single channel + // shows its own curve solid with the other two behind it, so you can see + // how far apart you have pulled them. + const S32 channel = getToneCurveChannel(); + const S32 plotted = (channel < 0) ? 0 : channel; + + const F32 t = toe.mV[plotted]; + const F32 s = shoulder.mV[plotted]; + const F32 k = strength.mV[plotted]; + + mToneCurve->setCurve([t, s, k](F32 x) { return ALCurveModel::smoothstep(x, t, s, k); }); + + mToneCurve->clearGhostCurves(); + if (channel >= 0) + { + static const LLColor4 channel_tint[3] = { + LLColor4(0.8f, 0.25f, 0.25f, 0.55f), + LLColor4(0.25f, 0.8f, 0.35f, 0.55f), + LLColor4(0.35f, 0.5f, 0.9f, 0.55f) }; + for (S32 i = 0; i < 3; ++i) + { + if (i == channel) + { + continue; + } + const F32 gt = toe.mV[i]; + const F32 gs = shoulder.mV[i]; + const F32 gk = strength.mV[i]; + mToneCurve->addGhostCurve( + [gt, gs, gk](F32 x) { return ALCurveModel::smoothstep(x, gt, gs, gk); }, + channel_tint[i]); } } - else if (setting_group == "tonemap") + + const F32 strength_x = findMaxDeviation(t, s); + + std::vector handles; + ALCurveEditorCtrl::Handle h; + + h.mName = "toe"; + h.mX = t; + h.mY = ALCurveModel::smoothstep(t, t, s, k); + h.mLockY = true; + handles.push_back(h); + + h = ALCurveEditorCtrl::Handle(); + h.mName = "shoulder"; + h.mX = s; + h.mY = ALCurveModel::smoothstep(s, t, s, k); + h.mLockY = true; + handles.push_back(h); + + h = ALCurveEditorCtrl::Handle(); + h.mName = "strength"; + h.mX = strength_x; + h.mY = ALCurveModel::smoothstep(strength_x, t, s, k); + h.mLockX = true; + handles.push_back(h); + + mToneCurve->setHandles(std::move(handles)); +} + +void ALFloaterLightBox::onCommitToneCurve() +{ + if (!mToneCurve) + { + return; + } + + const std::string which = mToneCurve->getActiveHandleName(); + const S32 index = mToneCurve->getActiveHandle(); + if (which.empty() || index < 0 || index >= (S32)mToneCurve->getHandles().size()) + { + return; + } + const ALCurveEditorCtrl::Handle handle = mToneCurve->getHandles()[index]; + + const S32 channel = getToneCurveChannel(); + const S32 read = (channel < 0) ? 0 : channel; + const F32 toe = getColor3(TONE_CURVE_TOE).mV[read]; + const F32 shoulder = getColor3(TONE_CURVE_SHOULDER).mV[read]; + const F32 strength = getColor3(TONE_CURVE_STRENGTH).mV[read]; + + // Writing a setting re-enters through its signal; let the write land, then + // rebuild the handles once from the values that actually stuck. Scoped so + // no early return can ever leave the flag stuck and the graph dead -- and + // the scope must close before the refresh below, which reads the same + // flag and would otherwise skip the rebuild it exists to do. { + ScopedTrue updating(mToneCurveUpdating); + + if (which == "toe") + { + // Held at or below the shoulder. The shader tolerates the inversion + // (its reciprocal is guarded) but it renders as a hard step with no + // visible cause; the spinners remain the way to ask for that. + setColor3Component(TONE_CURVE_TOE, channel, llmin(handle.mX, shoulder)); + } + else if (which == "shoulder") + { + setColor3Component(TONE_CURVE_SHOULDER, channel, llmax(handle.mX, toe)); + } + else if (which == "strength") { - LLControlVariable* controlp = gSavedSettings.getControl("RenderExposure"); - if (controlp) + // The handle rides the curve at a fixed x, so its height is + // mix(x, s, k) and k falls straight out of it. + const F32 x = handle.mX; + const F32 s = ALCurveModel::smoothstep(x, toe, shoulder, 1.f); + const F32 gap = s - x; + if (fabsf(gap) > 1e-3f) { - controlp->resetToDefault(true); + setColor3Component(TONE_CURVE_STRENGTH, channel, llclamp((handle.mY - x) / gap, 0.f, 1.f)); } } + } + refreshToneCurve(); +} + +void ALFloaterLightBox::setupSplitToneGraph() +{ + mSplitToneGraph = findChild("split_tone_graph"); + if (!mSplitToneGraph) + { + return; + } + + // Every input, not just the balance: the tints decide what colour a band + // is drawn in and the amounts decide how solid, so a graph that only + // watched the balance would sit there showing a tint the renderer had + // already stopped applying. + for (const char* key : { SPLIT_TONE_SHADOW, SPLIT_TONE_MIDTONE, SPLIT_TONE_HIGHLIGHT, + SPLIT_TONE_AMOUNT, SPLIT_TONE_MIDTONE_AMOUNT, SPLIT_TONE_BALANCE }) + { + if (LLControlVariable* controlp = gSavedSettings.getControl(key)) + { + mSplitToneConnections.emplace_back(controlp->getSignal()->connect( + [this](LLControlVariable*, const LLSD&, const LLSD&) { refreshSplitToneGraph(); })); + } + } + + refreshSplitToneGraph(); +} + +void ALFloaterLightBox::refreshSplitToneGraph() +{ + if (!mSplitToneGraph || mSplitToneUpdating) + { + return; + } + + const F32 mid = ALCurveModel::splitToneMid(gSavedSettings.getF32(SPLIT_TONE_BALANCE)); + const F32 amount = llclamp(gSavedSettings.getF32(SPLIT_TONE_AMOUNT), 0.f, 1.f); + const F32 mid_amount = llclamp(gSavedSettings.getF32(SPLIT_TONE_MIDTONE_AMOUNT), 0.f, 1.f); + + // A band wears the tint it applies. Normalised the way pipeline.cpp + // normalises it, so what the graph shows is the hue the renderer will + // actually use and a tint that is merely bright reads as neutral -- which + // is the truth, since the shader divides that brightness back out. + auto band_color = [](const char* key, F32 strength) + { + const LLColor3 tint = getColor3(key); + constexpr F32 LUMA_R = 0.2126f, LUMA_G = 0.7152f, LUMA_B = 0.0722f; + const F32 luma = llmax(tint.mV[0] * LUMA_R + tint.mV[1] * LUMA_G + tint.mV[2] * LUMA_B, 1e-4f); + + // Half brightness so a neutral band is mid grey and a saturated one has + // room to read as itself without clipping to white. + constexpr F32 DISPLAY_LEVEL = 0.5f; + LLColor4 out(llclamp(tint.mV[0] / luma * DISPLAY_LEVEL, 0.f, 1.f), + llclamp(tint.mV[1] / luma * DISPLAY_LEVEL, 0.f, 1.f), + llclamp(tint.mV[2] / luma * DISPLAY_LEVEL, 0.f, 1.f), + 1.f); + // Alpha follows the amount, so a band the renderer is ignoring fades + // instead of sitting at full strength claiming otherwise. It never + // reaches zero: where the bands lie is worth seeing even when nothing + // is being applied, and that is what the balance handle moves. + out.mV[VALPHA] = 0.16f + 0.44f * strength; + return out; + }; + + mSplitToneGraph->clearFillCurves(); + mSplitToneGraph->addFillCurve( + [mid](F32 l) { return ALCurveModel::splitToneWeights(l, mid).mShadow; }, + band_color(SPLIT_TONE_SHADOW, amount)); + mSplitToneGraph->addFillCurve( + [mid](F32 l) { return ALCurveModel::splitToneWeights(l, mid).mMidtone; }, + band_color(SPLIT_TONE_MIDTONE, mid_amount)); + mSplitToneGraph->addFillCurve( + [mid](F32 l) { return ALCurveModel::splitToneWeights(l, mid).mHighlight; }, + band_color(SPLIT_TONE_HIGHLIGHT, amount)); + + // The handle rides the peak of the midtone band, which is exactly where the + // two ramps cross and hand over -- the split point the setting names. Held + // to the horizontal, because moving it up or down would mean nothing. + ALCurveEditorCtrl::Handle handle; + handle.mName = "balance"; + handle.mX = mid; + handle.mY = 1.f; + handle.mLockY = true; + mSplitToneGraph->setHandles({ handle }); +} + +void ALFloaterLightBox::onCommitSplitToneGraph() +{ + if (!mSplitToneGraph || mSplitToneGraph->getHandles().empty()) + { + return; + } + + // Dragged past either end, the balance clamps and the refresh below puts + // the handle back where the setting actually landed, rather than leaving it + // parked somewhere the renderer will not follow. + const F32 mid = mSplitToneGraph->getHandles()[0].mX; + + // Scoped for the same reason onCommitToneCurve's guard is, and closed + // before the refresh for the same reason too. + { + ScopedTrue updating(mSplitToneUpdating); + gSavedSettings.setF32(SPLIT_TONE_BALANCE, ALCurveModel::splitToneBalance(mid)); + } + refreshSplitToneGraph(); +} + +void ALFloaterLightBox::onClickWhiteBalancePicker() +{ + ALToolScenePicker* picker = ALToolScenePicker::getInstance(); + + // A handle, not `this`. Two gaps let this floater die first: the tool stays + // armed until something is clicked, and the sample itself only arrives a + // frame after the click. This floater declares neither single_instance nor + // reuse_instance, so LLFloater::closeFloater destroys it outright -- arm + // the picker, close the Lightbox, click the world, and a raw pointer would + // be a use-after-free. + LLHandle handle = getDerivedHandle(); + picker->setPickCallback([handle](const LLColor3& sample) + { + if (ALFloaterLightBox* self = handle.get()) + { + self->onWhiteBalancePicked(sample); + } + }); + + LLToolMgr::getInstance()->setTransientTool(picker); +} + +void ALFloaterLightBox::onWhiteBalancePicked(const LLColor3& sample) +{ + // The ratios of three near-zero numbers are noise, and a sample in shadow + // is exactly where a click lands when someone misses. Refuse rather than + // swing the sliders to an answer read out of rounding error. + constexpr F32 MIN_LUMA = 1e-3f; + const F32 luma = sample.mV[0] * 0.2126f + sample.mV[1] * 0.7152f + sample.mV[2] * 0.0722f; + if (luma < MIN_LUMA) + { + LLNotificationsUtil::add("LightBoxWhiteBalanceTooDark"); + return; + } + + const ALWhiteBalanceSolver::Result solved = ALWhiteBalanceSolver::solveForColor(sample); + + // Past this the sliders cannot express what was asked for -- a strongly + // coloured surface, not a neutral one lit by coloured light. Applying the + // nearest reachable balance would look like the tool misfiring, so say so + // and change nothing. The threshold is well clear of the ~1e-3 a genuine + // solve returns and well below the 0.1-plus of a saturated sample. + constexpr F32 MAX_RESIDUAL = 0.02f; + if (solved.mResidual > MAX_RESIDUAL) + { + LLNotificationsUtil::add("LightBoxWhiteBalanceUnreachable"); + return; + } + + gSavedSettings.setF32("RenderColorGradeWhiteBalanceCCT", solved.mCCTOffset); + gSavedSettings.setF32("RenderColorGradeWhiteBalanceDuv", solved.mDuv); +} + +void ALFloaterLightBox::onLookSelected() +{ + const std::string name = mLooksCombo ? mLooksCombo->getSimple() : std::string(); + if (!name.empty()) + { + // Applying a Look writes every whitelisted key it carries. That is one + // choice, so it is one undo step -- and it is the step a user most + // wants back, having tried a Look on top of work they liked. + ScopedHistoryGroup group(mHistory); + LLPresetsManager::getInstance()->loadLooksPreset(name); + } +} + +void ALFloaterLightBox::onClickLookSave() +{ + std::string name = gSavedSettings.getString("PresetLooksActive"); + if (name.empty()) + { + name = gSavedSettings.getString("PresetLooksLastApplied"); + } + if (name.empty()) + { + // Nothing to overwrite yet; fall through to the name dialog + onClickLookSaveAs(); + return; + } + LLPresetsManager::getInstance()->savePreset(PRESETS_LOOKS, name); +} + +void ALFloaterLightBox::onClickLookSaveAs() +{ + LLFloaterReg::showInstance("save_pref_preset", LLSD(PRESETS_LOOKS)); +} + +void ALFloaterLightBox::onClickLookDelete() +{ + LLFloaterReg::showInstance("delete_pref_preset", LLSD(PRESETS_LOOKS)); +} + +void ALFloaterLightBox::onClickLookRevert() +{ + const std::string last = gSavedSettings.getString("PresetLooksLastApplied"); + if (!last.empty()) + { + // Same reasoning as onLookSelected: a revert throws away every edit + // since the Look was applied, and that had better be one Ctrl+Z. + ScopedHistoryGroup group(mHistory); + LLPresetsManager::getInstance()->loadLooksPreset(last); + } +} + +void ALFloaterLightBox::refreshLooksBar() +{ + if (!mLooksCombo) + { + return; + } + + LLPresetsManager::getInstance()->setPresetNamesInComboBox(PRESETS_LOOKS, mLooksCombo, DEFAULT_HIDE); + + const std::string active = gSavedSettings.getString("PresetLooksActive"); + const std::string last = gSavedSettings.getString("PresetLooksLastApplied"); + const bool modified = active.empty() && !last.empty(); + if (!active.empty()) + { + mLooksCombo->selectByValue(active); + } + else if (!last.empty()) + { + // The modified marker rides the name rather than sitting beside it as + // its own widget. Attached, it says which Look has been changed; a + // detached asterisk only says that something somewhere has. + // + // Display only: nothing selects while modified, which is the state + // this branch is, and onLookSelected reads the chosen item's own text + // rather than the combo's label -- so the marker cannot travel into a + // Look name. + LLStringUtil::format_map_t args; + args["[NAME]"] = last; + mLooksCombo->setLabel(getString("look_name_modified", args)); + } + getChild("look_save")->setEnabled(!active.empty() || !last.empty()); + getChild("look_delete")->setEnabled(mLooksCombo->getItemCount() > 0); + getChild("look_revert")->setEnabled(modified); +} + +void ALFloaterLightBox::updateTonemapperRows() +{ + // Khronos Neutral (0), ACES (1), and GT (5) take no parameters, so their + // selection leaves every per-operator row disabled. + const S32 type = gSavedSettings.getS32("AlchemyRenderTonemapType"); + static const std::pair param_rows[] = { + { "tone_aces_white", 2 }, + { "tone_reinhard_white", 3 }, + { "tone_filmic_white", 4 }, + { "tone_agx_contrast", 6 }, + { "tone_agx_white", 6 }, + }; + for (const auto& row : param_rows) + { + const bool active = (type == row.second); + getChild(row.first)->setEnabled(active); + getChild(std::string(row.first) + "_rst")->setEnabled(active); + } +} - //S32 tone_map_type = gSavedSettings.getS32("AlchemyRenderTonemapType"); - //switch (tone_map_type) - //{ - //case ALRenderUtil::TONEMAP_AMD: - //{ - // LLControlVariable* controlp = gSavedSettings.getControl("AlchemyToneMapAMDHDRMax"); - // if (controlp) - // { - // controlp->resetToDefault(true); - // } - // controlp = gSavedSettings.getControl("AlchemyToneMapAMDExposure"); - // if (controlp) - // { - // controlp->resetToDefault(true); - // } - // controlp = gSavedSettings.getControl("AlchemyToneMapAMDContrast"); - // if (controlp) - // { - // controlp->resetToDefault(true); - // } - // controlp = gSavedSettings.getControl("AlchemyToneMapAMDSaturationR"); - // if (controlp) - // { - // controlp->resetToDefault(true); - // } - // controlp = gSavedSettings.getControl("AlchemyToneMapAMDSaturationG"); - // if (controlp) - // { - // controlp->resetToDefault(true); - // } - // controlp = gSavedSettings.getControl("AlchemyToneMapAMDSaturationB"); - // if (controlp) - // { - // controlp->resetToDefault(true); - // } - // break; - //} - //case ALRenderUtil::TONEMAP_UCHIMURA: - //{ - // LLControlVariable* controlp = gSavedSettings.getControl("AlchemyToneMapUchimuraMaxBrightness"); - // if (controlp) - // { - // controlp->resetToDefault(true); - // } - // controlp = gSavedSettings.getControl("AlchemyToneMapUchimuraContrast"); - // if (controlp) - // { - // controlp->resetToDefault(true); - // } - // controlp = gSavedSettings.getControl("AlchemyToneMapUchimuraLinearStart"); - // if (controlp) - // { - // controlp->resetToDefault(true); - // } - // controlp = gSavedSettings.getControl("AlchemyToneMapUchimuraLinearLength"); - // if (controlp) - // { - // controlp->resetToDefault(true); - // } - // controlp = gSavedSettings.getControl("AlchemyToneMapUchimuraBlackLevel"); - // if (controlp) - // { - // controlp->resetToDefault(true); - // } - // break; - //} - //case ALRenderUtil::TONEMAP_UNCHARTED: - //{ - // LLControlVariable* controlp = gSavedSettings.getControl("AlchemyToneMapFilmicToeStr"); - // if (controlp) - // { - // controlp->resetToDefault(true); - // } - // controlp = gSavedSettings.getControl("AlchemyToneMapFilmicToeLen"); - // if (controlp) - // { - // controlp->resetToDefault(true); - // } - // controlp = gSavedSettings.getControl("AlchemyToneMapFilmicShoulderStr"); - // if (controlp) - // { - // controlp->resetToDefault(true); - // } - // controlp = gSavedSettings.getControl("AlchemyToneMapFilmicShoulderLen"); - // if (controlp) - // { - // controlp->resetToDefault(true); - // } - // controlp = gSavedSettings.getControl("AlchemyToneMapFilmicShoulderAngle"); - // if (controlp) - // { - // controlp->resetToDefault(true); - // } - // controlp = gSavedSettings.getControl("AlchemyToneMapFilmicGamma"); - // if (controlp) - // { - // controlp->resetToDefault(true); - // } - // controlp = gSavedSettings.getControl("AlchemyToneMapFilmicWhitePoint"); - // if (controlp) - // { - // controlp->resetToDefault(true); - // } - // break; - //} - //} - } -} - -void ALFloaterLightBox::updateTonemapper() -{ - //Init Text - LLTextBox* text1 = getChild("tonemapper_dynamic_text1"); - LLTextBox* text2 = getChild("tonemapper_dynamic_text2"); - LLTextBox* text3 = getChild("tonemapper_dynamic_text3"); - LLTextBox* text4 = getChild("tonemapper_dynamic_text4"); - LLTextBox* text5 = getChild("tonemapper_dynamic_text5"); - LLTextBox* text6 = getChild("tonemapper_dynamic_text6"); - LLTextBox* text7 = getChild("tonemapper_dynamic_text7"); - - //Init Spinners - LLSpinCtrl* spinner1 = getChild("tonemapper_dynamic_spinner1"); - LLSpinCtrl* spinner2 = getChild("tonemapper_dynamic_spinner2"); - LLSpinCtrl* spinner3 = getChild("tonemapper_dynamic_spinner3"); - LLSpinCtrl* spinner4 = getChild("tonemapper_dynamic_spinner4"); - LLSpinCtrl* spinner5 = getChild("tonemapper_dynamic_spinner5"); - LLSpinCtrl* spinner6 = getChild("tonemapper_dynamic_spinner6"); - LLSpinCtrl* spinner7 = getChild("tonemapper_dynamic_spinner7"); - - // Init Sliders - LLSliderCtrl* slider1 = getChild("tonemapper_dynamic_slider1"); - LLSliderCtrl* slider2 = getChild("tonemapper_dynamic_slider2"); - LLSliderCtrl* slider3 = getChild("tonemapper_dynamic_slider3"); - LLSliderCtrl* slider4 = getChild("tonemapper_dynamic_slider4"); - LLSliderCtrl* slider5 = getChild("tonemapper_dynamic_slider5"); - LLSliderCtrl* slider6 = getChild("tonemapper_dynamic_slider6"); - LLSliderCtrl* slider7 = getChild("tonemapper_dynamic_slider7"); - - // Check the state of AlchemyRenderTonemapType - /* switch (gSavedSettings.getS32("AlchemyRenderTonemapType")) - { - default: - { - text1->setVisible(false); - spinner1->setVisible(false); - slider1->setVisible(false); - - text2->setVisible(false); - spinner2->setVisible(false); - slider2->setVisible(false); - - text3->setVisible(false); - spinner3->setVisible(false); - slider3->setVisible(false); - - text4->setVisible(false); - spinner4->setVisible(false); - slider4->setVisible(false); - - text5->setVisible(false); - spinner5->setVisible(false); - slider5->setVisible(false); - - text6->setVisible(false); - spinner6->setVisible(false); - slider6->setVisible(false); - - text7->setVisible(false); - spinner7->setVisible(false); - slider7->setVisible(false); - break; - } - case ALRenderUtil::TONEMAP_UCHIMURA: - { - text1->setVisible(true); - text1->setText(std::string("Max Brightness")); - spinner1->setVisible(true); - spinner1->setMinValue(0.01f); - spinner1->setMaxValue(8.0f); - spinner1->setIncrement(0.1f); - spinner1->setControlName("AlchemyToneMapUchimuraMaxBrightness"); - slider1->setVisible(true); - slider1->setMinValue(0.01f); - slider1->setMaxValue(8.0f); - slider1->setIncrement(0.1f); - slider1->setControlName("AlchemyToneMapUchimuraMaxBrightness", nullptr); - - text2->setVisible(true); - text2->setText(std::string("Contrast")); - spinner2->setVisible(true); - spinner2->setMinValue(0.01f); - spinner2->setMaxValue(2.0f); - spinner2->setIncrement(0.01f); - spinner2->setControlName("AlchemyToneMapUchimuraContrast"); - slider2->setVisible(true); - slider2->setMinValue(0.01f); - slider2->setMaxValue(2.0f); - slider2->setIncrement(0.01f); - slider2->setControlName("AlchemyToneMapUchimuraContrast", nullptr); - - text3->setVisible(true); - text3->setText(std::string("Linear Start")); - spinner3->setVisible(true); - spinner3->setMinValue(0.01f); - spinner3->setMaxValue(1.0f); - spinner3->setIncrement(0.01f); - spinner3->setControlName("AlchemyToneMapUchimuraLinearStart"); - slider3->setVisible(true); - slider3->setMinValue(0.01f); - slider3->setMaxValue(1.0f); - slider3->setIncrement(0.01f); - slider3->setControlName("AlchemyToneMapUchimuraLinearStart", nullptr); - - text4->setVisible(true); - text4->setText(std::string("Linear Length")); - spinner4->setVisible(true); - spinner4->setMinValue(0.01f); - spinner4->setMaxValue(1.0f); - spinner4->setIncrement(0.01f); - spinner4->setControlName("AlchemyToneMapUchimuraLinearLength"); - slider4->setVisible(true); - slider4->setMinValue(0.01f); - slider4->setMaxValue(1.0f); - slider4->setIncrement(0.01f); - slider4->setControlName("AlchemyToneMapUchimuraLinearLength", nullptr); - - text5->setVisible(true); - text5->setText(std::string("Black Level")); - spinner5->setVisible(true); - spinner5->setMinValue(0.01f); - spinner5->setMaxValue(4.0f); - spinner5->setIncrement(0.01f); - spinner5->setControlName("AlchemyToneMapUchimuraBlackLevel"); - slider5->setVisible(true); - slider5->setMinValue(0.01f); - slider5->setMaxValue(4.0f); - slider5->setIncrement(0.01f); - slider5->setControlName("AlchemyToneMapUchimuraBlackLevel", nullptr); - - text6->setVisible(false); - spinner6->setVisible(false); - slider6->setVisible(false); - - text7->setVisible(false); - spinner7->setVisible(false); - slider7->setVisible(false); - break; - } - case ALRenderUtil::TONEMAP_AMD: - { - text1->setVisible(true); - text1->setText(std::string("HDR Max")); - spinner1->setVisible(true); - spinner1->setMinValue(1.0f); - spinner1->setMaxValue(512.0f); - spinner1->setIncrement(1.f); - spinner1->setControlName("AlchemyToneMapAMDHDRMax"); - slider1->setVisible(true); - slider1->setMinValue(1.0f); - slider1->setMaxValue(512.0f); - slider1->setIncrement(1.f); - slider1->setControlName("AlchemyToneMapAMDHDRMax", nullptr); - - text2->setVisible(true); - text2->setText(std::string("Tone Exposure")); - spinner2->setVisible(true); - spinner2->setMinValue(1.0f); - spinner2->setMaxValue(16.0f); - spinner2->setIncrement(0.1f); - spinner2->setControlName("AlchemyToneMapAMDExposure"); - slider2->setVisible(true); - slider2->setMinValue(1.0f); - slider2->setMaxValue(16.0f); - slider2->setIncrement(0.1f); - slider2->setControlName("AlchemyToneMapAMDExposure", nullptr); - - text3->setVisible(true); - text3->setText(std::string("Contrast")); - spinner3->setVisible(true); - spinner3->setMinValue(0.0f); - spinner3->setMaxValue(1.0f); - spinner3->setIncrement(0.01f); - spinner3->setControlName("AlchemyToneMapAMDContrast"); - slider3->setVisible(true); - slider3->setMinValue(0.0f); - slider3->setMaxValue(1.0f); - slider3->setIncrement(0.01f); - slider3->setControlName("AlchemyToneMapAMDContrast", nullptr); - - text4->setVisible(true); - text4->setText(std::string("R Saturation")); - spinner4->setVisible(true); - spinner4->setMinValue(-2.0f); - spinner4->setMaxValue(2.0f); - spinner4->setIncrement(0.1f); - spinner4->setControlName("AlchemyToneMapAMDSaturationR"); - slider4->setVisible(true); - slider4->setMinValue(-2.0f); - slider4->setMaxValue(2.0f); - slider4->setIncrement(0.1f); - slider4->setControlName("AlchemyToneMapAMDSaturationR", nullptr); - - text5->setVisible(true); - text5->setText(std::string("G Saturation")); - spinner5->setVisible(true); - spinner5->setMinValue(-2.0f); - spinner5->setMaxValue(2.0f); - spinner5->setIncrement(0.1f); - spinner5->setControlName("AlchemyToneMapAMDSaturationG"); - slider5->setVisible(true); - slider5->setMinValue(-2.0f); - slider5->setMaxValue(2.0f); - slider5->setIncrement(0.1f); - slider5->setControlName("AlchemyToneMapAMDSaturationG", nullptr); - - text6->setVisible(true); - text6->setText(std::string("B Saturation")); - spinner6->setVisible(true); - spinner6->setMinValue(-2.0f); - spinner6->setMaxValue(2.0f); - spinner6->setIncrement(0.1f); - spinner6->setControlName("AlchemyToneMapAMDSaturationB"); - slider6->setVisible(true); - slider6->setMinValue(-2.0f); - slider6->setMaxValue(2.0f); - slider6->setIncrement(0.1f); - slider6->setControlName("AlchemyToneMapAMDSaturationB", nullptr); - - text7->setVisible(false); - spinner7->setVisible(false); - slider7->setVisible(false); - break; - } - case ALRenderUtil::TONEMAP_UNCHARTED: - { - text1->setVisible(true); - text1->setText(std::string("Toe Strength")); - spinner1->setVisible(true); - spinner1->setMinValue(0.0f); - spinner1->setMaxValue(1.0f); - spinner1->setIncrement(0.01f); - spinner1->setControlName("AlchemyToneMapFilmicToeStr"); - slider1->setVisible(true); - slider1->setMinValue(0.0f); - slider1->setMaxValue(1.0f); - slider1->setIncrement(0.01f); - slider1->setControlName("AlchemyToneMapFilmicToeStr", nullptr); - - text2->setVisible(true); - text2->setText(std::string("Toe Length")); - spinner2->setVisible(true); - spinner2->setMinValue(0.01f); - spinner2->setMaxValue(1.0f); - spinner2->setIncrement(0.01f); - spinner2->setControlName("AlchemyToneMapFilmicToeLen"); - slider2->setVisible(true); - slider2->setMinValue(0.01f); - slider2->setMaxValue(1.0f); - slider2->setIncrement(0.01f); - slider2->setControlName("AlchemyToneMapFilmicToeLen", nullptr); - - text3->setVisible(true); - text3->setText(std::string("Shoulder Strength")); - spinner3->setVisible(true); - spinner3->setMinValue(0.0f); - spinner3->setMaxValue(1.0f); - spinner3->setIncrement(0.01f); - spinner3->setControlName("AlchemyToneMapFilmicShoulderStr"); - slider3->setVisible(true); - slider3->setMinValue(0.0f); - slider3->setMaxValue(1.0f); - slider3->setIncrement(0.01f); - slider3->setControlName("AlchemyToneMapFilmicShoulderStr", nullptr); - - text4->setVisible(true); - text4->setText(std::string("Shoulder Length")); - spinner4->setVisible(true); - spinner4->setMinValue(0.01f); - spinner4->setMaxValue(8.0f); - spinner4->setIncrement(0.01f); - spinner4->setControlName("AlchemyToneMapFilmicShoulderLen"); - slider4->setVisible(true); - slider4->setMinValue(0.01f); - slider4->setMaxValue(8.0f); - slider4->setIncrement(0.01f); - slider4->setControlName("AlchemyToneMapFilmicShoulderLen", nullptr); - - text5->setVisible(true); - text5->setText(std::string("Shoulder Angle")); - spinner5->setVisible(true); - spinner5->setMinValue(0.0f); - spinner5->setMaxValue(1.0f); - spinner5->setIncrement(0.01f); - spinner5->setControlName("AlchemyToneMapFilmicShoulderAngle"); - slider5->setVisible(true); - slider5->setMinValue(0.0f); - slider5->setMaxValue(1.0f); - slider5->setIncrement(0.01f); - slider5->setControlName("AlchemyToneMapFilmicShoulderAngle", nullptr); - - text6->setVisible(true); - text6->setText(std::string("Gamma")); - spinner6->setVisible(true); - spinner6->setMinValue(0.01f); - spinner6->setMaxValue(5.0f); - spinner6->setIncrement(0.01f); - spinner6->setControlName("AlchemyToneMapFilmicGamma"); - slider6->setVisible(true); - slider6->setMinValue(0.01f); - slider6->setMaxValue(5.0f); - slider6->setIncrement(0.01f); - slider6->setControlName("AlchemyToneMapFilmicGamma", nullptr); - - text7->setVisible(true); - text7->setText(std::string("White Point")); - spinner7->setVisible(true); - spinner7->setMinValue(1.0f); - spinner7->setMaxValue(16.0f); - spinner7->setIncrement(0.1f); - spinner7->setControlName("AlchemyToneMapFilmicWhitePoint"); - slider7->setVisible(true); - slider7->setMinValue(1.0f); - slider7->setMaxValue(16.0f); - slider7->setIncrement(0.1f); - slider7->setControlName("AlchemyToneMapFilmicWhitePoint", nullptr); - break; - } - }*/ -} - -void ALFloaterLightBox::updateCAS() -{ - // Init UI - LLTextBox* text2 = getChild("sharp_dynamic_text"); - LLSpinCtrl* spinner1 = getChild("sharp_strength_spinner"); - LLSpinCtrl* spinner2 = getChild("sharp_dynamic_spinner"); - LLSliderCtrl* slider1 = getChild("sharp_strength_slider"); - LLSliderCtrl* slider2 = getChild("sharp_dynamic_slider"); - - //switch (gSavedSettings.getU32("RenderSharpenMethod")) - //{ - //default: - //case ALRenderUtil::SHARPEN_NONE: - //{ - // spinner1->setVisible(false); - // slider1->setVisible(false); - // text2->setVisible(false); - // spinner2->setVisible(false); - // slider2->setVisible(false); - // break; - //} - //case ALRenderUtil::SHARPEN_CAS: - //{ - // spinner1->setVisible(true); - // spinner1->setMinValue(0.0f); - // spinner1->setMaxValue(1.0f); - // spinner1->setIncrement(0.1f); - // spinner1->setControlName("RenderSharpenCASSharpness"); - // slider1->setVisible(true); - // slider1->setMinValue(0.0f); - // slider1->setMaxValue(1.0f); - // slider1->setIncrement(0.1f); - // slider1->setControlName("RenderSharpenCASSharpness", nullptr); - - // text2->setVisible(false); - // spinner2->setVisible(false); - // slider2->setVisible(false); - // break; - //} - //case ALRenderUtil::SHARPEN_DLS: - //{ - // spinner1->setVisible(true); - // spinner1->setMinValue(0.0f); - // spinner1->setMaxValue(1.0f); - // spinner1->setIncrement(0.1f); - // spinner1->setControlName("RenderSharpenDLSSharpness"); - // slider1->setVisible(true); - // slider1->setMinValue(0.0f); - // slider1->setMaxValue(1.0f); - // slider1->setIncrement(0.1f); - // slider1->setControlName("RenderSharpenDLSSharpness", nullptr); - - // text2->setVisible(true); - // text2->setText(std::string("Denoise:")); - // spinner2->setVisible(true); - // spinner2->setMinValue(0.0f); - // spinner2->setMaxValue(1.0f); - // spinner2->setIncrement(0.1f); - // spinner2->setControlName("RenderSharpenDLSDenoise"); - // slider2->setVisible(true); - // slider2->setMinValue(0.0f); - // slider2->setMaxValue(1.0f); - // slider2->setIncrement(0.1f); - // slider2->setControlName("RenderSharpenDLSDenoise", nullptr); - // break; - //} - //} +void ALFloaterLightBox::refreshVec3Row(const std::string& setting_name) +{ + auto row = mVec3Rows.find(setting_name); + LLControlVariable* controlp = gSavedSettings.getControl(setting_name); + if (row == mVec3Rows.end() || !controlp) + { + return; + } + + const LLSD value = controlp->getValue(); + ScopedTrue updating(mVec3Updating); + for (S32 i = 0; i < 3; ++i) + { + if (LLUICtrl* ctrlp = row->second[i]) + { + ctrlp->setValue(value[i].asReal()); + } + } } diff --git a/indra/newview/alfloaterlightbox.h b/indra/newview/alfloaterlightbox.h index e53e886e5b..7b835447a7 100644 --- a/indra/newview/alfloaterlightbox.h +++ b/indra/newview/alfloaterlightbox.h @@ -1,6 +1,6 @@ /** * @file alfloaterlightbox.h - * @brief A generic text floater for dumping info (usually debug info) + * @brief Lightbox post-processing control floater * * Copyright (c) Rye Mutt * @@ -34,7 +34,22 @@ #define AL_FLOATERLIGHTBOX_H #include "llfloater.h" + +#include "aldaycyclelandmarks.h" +#include "algradehistory.h" + +#include +#include +#include #include +#include + +class ALCurveEditorCtrl; +class LLComboBox; +class LLSettingsDay; +class LLSettingsSky; +class LLSettingsWater; +class LLSpinCtrl; class ALFloaterLightBox final : public LLFloater { @@ -42,17 +57,194 @@ class ALFloaterLightBox final : public LLFloater ALFloaterLightBox(const LLSD& key); ~ALFloaterLightBox() override; bool postBuild() override; - virtual void draw() override; + /// Ctrl+Z / Ctrl+Y (and Ctrl+Shift+Z) drive the grade's undo stack. + /// + /// Floater-local rather than a global action, unlike the hold-to-compare + /// key: a global Ctrl+Z would fire while the user is typing anywhere in + /// the viewer. Being handled here also means a text field that wants + /// Ctrl+Z for its own undo gets it first, since the focus chain is offered + /// the key before the floater is. + bool handleKeyHere(KEY key, MASK mask) override; + /// Only to notice the reference still appearing or going away, which is + /// render state and so has no signal to hang on. See refreshReferenceRow. + void draw() override; private: void onClickResetControlDefault(const LLSD& userdata); - void onClickResetGroupDefault(const LLSD& userdata); - void updateTonemapper(); - void updateCAS(); + void onClickResetSection(const LLSD& userdata); + /// Set or clear one section's bit in LLPipeline::sGradeBypassMask, from + /// the checkbox on that section's accordion header. Ticked means the + /// section is applied, which is the only way a checkbox beside a section + /// title reads; the bit it drives is a bypass bit, so the sense inverts + /// here. The settings are never touched, so a comparison cannot dirty + /// the Look. + void onToggleSection(LLUICtrl* ctrl, const LLSD& userdata); + void onCommitVec3(LLUICtrl* ctrl); + void refreshVec3Row(const std::string& setting_name); + void setupToneCurve(); + void refreshToneCurve(); + void onCommitToneCurve(); + /// Which channel the tone curve graph edits: -1 for all three, else 0..2. + S32 getToneCurveChannel() const; + void setupSplitToneGraph(); + void refreshSplitToneGraph(); + void onCommitSplitToneGraph(); + /// Arm the scene picker; the click that follows sets Temperature and Tint. + void onClickWhiteBalancePicker(); + void onWhiteBalancePicked(const LLColor3& sample); void populateLUTCombo(); + /// Open the user's LUT folder in the platform file browser, creating it + /// first if this is its first use. + void onClickOpenLUTFolder(); + void updateTonemapperRows(); + /// Freeze the frame about to be presented, and switch the wipe on so the + /// grab is visibly a grab. + void onClickReferenceGrab(); + /// Drop the still and switch the wipe off. + void onClickReferenceClear(); + /// Grey the wipe controls while there is nothing to wipe against. + void refreshReferenceRow(); + /// Grey Undo and Redo to match what the stack can actually do. + void refreshHistoryButtons(); + + // --- Day cycle --- + // + // Nothing here pauses a clock, because there is no clock to pause: the + // cycle position is computed from wall time every frame. Freezing means + // sampling the running cycle at one position and installing the result as + // a *fixed* local environment, which is what stops the motion. It is the + // same move `@setenv_daytime` and the day cycle editor's timeline make. + + /// The day cycle to scrub, from the highest-priority layer that has one. + /// While frozen, ENV_LOCAL holds a fixed sky and no day at all, so the + /// cycle has to be read from the parcel or region underneath it -- which + /// is also what lets scrubbing survive closing and reopening the floater. + std::shared_ptr getScrubbableDay() const; + /// Whether a fixed sky is installed locally. Derived from the world rather + /// than remembered here, so it stays true when something else changes the + /// environment behind our back. + bool isSkyFrozen() const; + /// Sample the day at `position` and install it as the local environment. + void applyDayPosition(F32 position); + /// Remember what we are covering, then freeze. + void freezeSkyAt(F32 position); + /// Put back whatever ENV_LOCAL held before the freeze, or clear it. + void thawSky(); + /// Re-find the landmarks if the underlying day cycle has changed. Blends + /// ninety-six skies, so it is guarded on the day itself, not called per + /// frame. + void refreshDayLandmarks(); + /// Track the world's state and grey what cannot act on it. + void refreshDayCycleRow(); + + void onToggleDayFreeze(LLUICtrl* ctrl); + void onCommitDayTime(LLUICtrl* ctrl); + void onClickDayPreset(const LLSD& userdata); + void onToggleCloudScroll(LLUICtrl* ctrl); + void onClickRestoreEnvironment(); + + /// Record one whitelisted setting moving, unless we are the ones moving it. + void onGradeSettingChanged(const std::string& name, const LLSD& before, const LLSD& after); + /// Step the history one transaction and write the values back. + /// @return false when there was nothing in that direction. + bool applyHistory(bool redo_direction); + + void onLookSelected(); + void onClickLookSave(); + void onClickLookSaveAs(); + void onClickLookDelete(); + void onClickLookRevert(); + void refreshLooksBar(); + // Spinner triplets named "vec3__<0|1|2>", keyed by setting name. + // Rows are discovered by walking the widget tree in postBuild; adding a + // vector-valued row is pure XUI. + /// Any LLUICtrl, not just LLSpinCtrl: the contract is the widget's *name*, + /// so a slider, a spinner or anything else that carries one number can + /// stand for a component. Sliders matter for banks of related values -- + /// eight hue sliders read as a shape, eight spinners read as a form. + std::map> mVec3Rows; + std::vector mVec3Connections; boost::signals2::scoped_connection mTonemapConnection; - boost::signals2::scoped_connection mCASConnection; + boost::signals2::scoped_connection mLooksListConnection; + boost::signals2::scoped_connection mLooksActiveConnection; + LLComboBox* mLooksCombo = nullptr; + bool mVec3Updating = false; + + // Tone curve graph. Optional: the floater builds without it, so the XUI + // can drop the graph and keep the spinners. + std::vector mToneCurveConnections; + ALCurveEditorCtrl* mToneCurve = nullptr; + LLComboBox* mToneCurveChannel = nullptr; + bool mToneCurveUpdating = false; + + // Split-tone band graph. Same contract: optional, and the sliders below it + // remain the full interface if the XUI drops it. + std::vector mSplitToneConnections; + ALCurveEditorCtrl* mSplitToneGraph = nullptr; + bool mSplitToneUpdating = false; + + // Undo. The history is a member, so it lives and dies with the floater: + // close the Lightbox and the stack is gone. That is the honest scope -- + // the alternative is a stack that outlives the window it belongs to and + // silently rewrites settings a much later session is editing. + ALGradeHistory mHistory; + std::vector mHistoryConnections; + /// Set while applyHistory writes settings. Those writes fire the same + /// signals a user edit does, and recording them would append the undo to + /// the stack it came from. + bool mApplyingHistory = false; + + // Undo and Redo in the top bar, polled by draw() like the row below: + // the stack moves on every commit and every Look apply, and hanging a + // refresh off each of those is more places to forget. + LLUICtrl* mUndoButton = nullptr; + LLUICtrl* mRedoButton = nullptr; + /// Bit 0 can-undo, bit 1 can-redo; -1 until the first refresh. + S32 mHistoryButtonState = -1; + + // Reference still row. Cached because draw() polls them, and optional + // because the XUI is free to drop the row. + LLUICtrl* mReferenceClear = nullptr; + LLUICtrl* mReferenceMode = nullptr; + LLUICtrl* mReferencePosition = nullptr; + /// What the row was last told, so a poll that changes nothing costs + /// nothing. Tri-state: -1 until the first refresh has run. + S32 mReferenceRowState = -1; + + // Day cycle row, cached for the same reason and equally optional. + LLUICtrl* mDayFreeze = nullptr; + LLUICtrl* mDayTime = nullptr; + LLUICtrl* mCloudScroll = nullptr; + LLUICtrl* mRestoreEnvironment = nullptr; + /// Sunrise, noon, sunset, midnight, in the order the landmarks are named. + std::array mDayPresets = {}; + + /// Where the slider is. While the sky is running this tracks the live + /// position, so ticking Freeze holds the moment being looked at rather + /// than jumping somewhere else first. + F32 mDayPosition = 0.f; + /// Landmarks, and the day they were found in. Comparing the day is what + /// keeps the search off the frame path. + std::shared_ptr mLandmarkDay; + ALDayCycleLandmarks::Landmarks mDayLandmarks; + + /// What ENV_LOCAL held before we froze it. Restoring this is what makes + /// unticking Freeze an undo instead of a drop to the region default, which + /// would silently discard a Personal Lighting sky. + std::shared_ptr mPreFreezeDay; + std::shared_ptr mPreFreezeSky; + std::shared_ptr mPreFreezeWater; + /// Whole seconds: LLSettingsDay::Seconds is S32Seconds, unlike the F64 one + /// on LLSettingsBase, and holding these as F32 would convert lossily on + /// the way back in. + S32 mPreFreezeDayLength = 0; + S32 mPreFreezeDayOffset = 0; + /// Whether the fixed sky in place is one we installed. False for a sky the + /// user set some other way, which we must not claim to be able to undo. + bool mDayFreezeIsOurs = false; + /// Bit 0 may change the environment, bit 1 frozen, bit 2 a day to scrub. + S32 mDayCycleRowState = -1; }; #endif // AL_FLOATERLIGHTBOX_H diff --git a/indra/newview/alfloaterscopes.cpp b/indra/newview/alfloaterscopes.cpp new file mode 100644 index 0000000000..e77c4de296 --- /dev/null +++ b/indra/newview/alfloaterscopes.cpp @@ -0,0 +1,806 @@ +/** + * @file alfloaterscopes.cpp + * @brief Histogram of the frame the viewer is presenting + * + * $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$ + */ + +#include "llviewerprecompiledheaders.h" + +#include "alfloaterscopes.h" + +#include "alcolorwheelmodel.h" +#include "llcombobox.h" +#include "llfontgl.h" +#include "lllocalcliprect.h" +#include "llmenugl.h" +#include "llpanel.h" +#include "llrender.h" +#include "llrender2dutils.h" +#include "lltextbox.h" +#include "lltrans.h" +#include "lluicolortable.h" +#include "lluictrlfactory.h" +#include "llviewercontrol.h" +#include "llviewermenu.h" // gMenuHolder +#include "llviewerwindow.h" +#include "pipeline.h" + +#include + +namespace +{ +const LLColor4 CHANNEL_COLOR[ALScopeData::CH_COUNT] = { + LLColor4(0.95f, 0.30f, 0.30f, 0.75f), // red + LLColor4(0.30f, 0.92f, 0.42f, 0.75f), // green + LLColor4(0.40f, 0.58f, 0.98f, 0.75f), // blue + LLColor4(0.88f, 0.88f, 0.88f, 0.85f), // luma +}; + +/// Curve steepness for the log scale. Chosen so a bin holding a thousandth of +/// the sample still draws about a fifth of the plot's height -- tall enough to +/// see, short enough not to be mistaken for a peak. +constexpr F32 LOG_SCALE_K = 400.f; + +/// Space between panes. Wide enough to read as a division rather than a drawing +/// artefact, narrow enough not to eat a small window: at the floater's minimum +/// size a quad layout has about sixty pixels of plot height per pane, and this +/// takes two of them. +constexpr S32 PANE_GAP = 4; +} + +ALFloaterScopes::ALFloaterScopes(const LLSD& key) +: LLFloater(key) +{ + // Registered here rather than in postBuild because the context menu is + // built from XML during postBuild and resolves these names as it parses. + mCommitCallbackRegistrar.add("Scopes.SetPaneMode", + boost::bind(&ALFloaterScopes::onPaneModePicked, this, _2)); + mEnableCallbackRegistrar.add("Scopes.IsPaneMode", + boost::bind(&ALFloaterScopes::isPaneModeChecked, this, _2)); +} + +ALFloaterScopes::~ALFloaterScopes() +{ + // Belt and braces: onClose already cleared it, but a floater destroyed + // without closing would otherwise leave the renderer sampling forever. + LLPipeline::sScopeCapture = false; + + // The menu lives in gMenuHolder, not in this floater's view tree, so + // nothing else is going to take it down with us. + if (auto* menu = static_cast(mPopupMenuHandle.get())) + { + menu->die(); + mPopupMenuHandle.markDead(); + } +} + +bool ALFloaterScopes::postBuild() +{ + mPlotPanel = getChild("scope_plot"); + mLayoutCombo = getChild("scope_layout"); + mClipReadout = getChild("scope_clipping"); + + if (auto* menu = LLUICtrlFactory::getInstance()->createFromFile( + "menu_scopes_pane.xml", gMenuHolder, LLViewerMenuHolderGL::child_registry_t::instance())) + { + mPopupMenuHandle = menu->getHandle(); + } + + return LLFloater::postBuild(); +} + +void ALFloaterScopes::onOpen(const LLSD& key) +{ + LLPipeline::sScopeCapture = true; + LLFloater::onOpen(key); +} + +void ALFloaterScopes::onClose(bool app_quitting) +{ + LLPipeline::sScopeCapture = false; + LLFloater::onClose(app_quitting); +} + +ALFloaterScopes::ELayout ALFloaterScopes::getLayout() const +{ + static LLCachedControl layout(gSavedSettings, "AlchemyScopeLayout", LAYOUT_SINGLE); + // Clamped rather than trusted: the setting is user-editable and persisted, + // and a bad value here would index the pane table out of range. + return (ELayout)llclamp(layout(), (S32)LAYOUT_SINGLE, (S32)LAYOUT_COUNT - 1); +} + +S32 ALFloaterScopes::getPaneCount() const +{ + switch (getLayout()) + { + case LAYOUT_COLUMNS: + case LAYOUT_ROWS: + return 2; + case LAYOUT_QUAD: + return MAX_PANES; + case LAYOUT_SINGLE: + default: + return 1; + } +} + +ALFloaterScopes::EMode ALFloaterScopes::getPaneMode(S32 pane) const +{ + if (pane < 0 || pane >= MAX_PANES) + { + return MODE_RGB; + } + // One control per pane rather than one packed value, so each is separately + // readable and editable in Debug Settings like every other scope control. + const std::string name = llformat("AlchemyScopePane%d", pane); + return (EMode)llclamp(gSavedSettings.getS32(name), (S32)MODE_RGB, (S32)MODE_COUNT - 1); +} + +void ALFloaterScopes::setPaneMode(S32 pane, EMode mode) +{ + if (pane < 0 || pane >= MAX_PANES || mode < MODE_RGB || mode >= MODE_COUNT) + { + return; + } + gSavedSettings.setS32(llformat("AlchemyScopePane%d", pane), (S32)mode); +} + +// static +const char* ALFloaterScopes::modeStringName(EMode mode) +{ + switch (mode) + { + case MODE_LUMA: return "mode_luma"; + case MODE_RED: return "mode_red"; + case MODE_GREEN: return "mode_green"; + case MODE_BLUE: return "mode_blue"; + case MODE_VECTOR: return "mode_vector"; + case MODE_WAVE_LUMA: return "mode_wave_luma"; + case MODE_WAVE_RGB: return "mode_wave_rgb"; + case MODE_PARADE: return "mode_parade"; + case MODE_RGB: + default: return "mode_rgb"; + } +} + +S32 ALFloaterScopes::computePaneRects(LLRect (&out)[MAX_PANES]) const +{ + if (!mPlotPanel) + { + return 0; + } + + const LLRect area = mPlotPanel->getRect(); + const S32 count = getPaneCount(); + + // Halves are taken from the far edge rather than by adding the first half's + // width, so an odd number of pixels lands in one pane instead of leaving a + // one-pixel strip of background between them. + const S32 mid_x = area.mLeft + (area.getWidth() - PANE_GAP) / 2; + const S32 mid_y = area.mBottom + (area.getHeight() - PANE_GAP) / 2; + + switch (getLayout()) + { + case LAYOUT_COLUMNS: + out[0] = LLRect(area.mLeft, area.mTop, mid_x, area.mBottom); + out[1] = LLRect(mid_x + PANE_GAP, area.mTop, area.mRight, area.mBottom); + break; + + case LAYOUT_ROWS: + // Pane 0 on top: panes read in the order you assign them, and the eye + // starts at the top of a window rather than the bottom. + out[0] = LLRect(area.mLeft, area.mTop, area.mRight, mid_y + PANE_GAP); + out[1] = LLRect(area.mLeft, mid_y, area.mRight, area.mBottom); + break; + + case LAYOUT_QUAD: + out[0] = LLRect(area.mLeft, area.mTop, mid_x, mid_y + PANE_GAP); + out[1] = LLRect(mid_x + PANE_GAP, area.mTop, area.mRight, mid_y + PANE_GAP); + out[2] = LLRect(area.mLeft, mid_y, mid_x, area.mBottom); + out[3] = LLRect(mid_x + PANE_GAP, mid_y, area.mRight, area.mBottom); + break; + + case LAYOUT_SINGLE: + default: + out[0] = area; + break; + } + + return count; +} + +S32 ALFloaterScopes::paneAt(S32 x, S32 y) const +{ + LLRect rects[MAX_PANES]; + const S32 count = computePaneRects(rects); + for (S32 i = 0; i < count; ++i) + { + if (rects[i].pointInRect(x, y)) + { + return i; + } + } + return -1; +} + +// static +bool ALFloaterScopes::isWaveMode(EMode mode) +{ + return mode == MODE_WAVE_LUMA || mode == MODE_WAVE_RGB || mode == MODE_PARADE; +} + +bool ALFloaterScopes::useLogScale() const +{ + static LLCachedControl log_scale(gSavedSettings, "AlchemyScopeLogScale", true); + return log_scale(); +} + +F32 ALFloaterScopes::barHeight(F32 share, F32 peak) const +{ + if (peak <= 0.f || share <= 0.f) + { + return 0.f; + } + if (!useLogScale()) + { + return llclamp(share / peak, 0.f, 1.f); + } + // Normalised log so the tallest bin still reaches the top of the plot, + // whatever the peak happens to be. + const F32 num = logf(1.f + share * LOG_SCALE_K); + const F32 den = logf(1.f + peak * LOG_SCALE_K); + return (den > 0.f) ? llclamp(num / den, 0.f, 1.f) : 0.f; +} + +void ALFloaterScopes::drawChannel(const ALScopeData& data, ALScopeData::EChannel channel, + const LLColor4& color, const LLRect& plot, bool filled) const +{ + const F32 peak = data.getPeak(channel); + if (peak <= 0.f) + { + return; + } + + const F32 width = (F32)plot.getWidth(); + const F32 height = (F32)plot.getHeight(); + const F32 base = (F32)plot.mBottom; + + // One point per bin, at the bin's centre. + std::vector outline; + outline.reserve(ALScopeData::BIN_COUNT); + for (S32 i = 0; i < ALScopeData::BIN_COUNT; ++i) + { + const F32 h = barHeight(data.getBin(channel, i), peak) * height; + const F32 x = plot.mLeft + width * ((F32)i + 0.5f) / (F32)ALScopeData::BIN_COUNT; + outline.emplace_back(x, base + h); + } + + if (filled) + { + // Filled as trapezoids between neighbouring bin centres rather than as + // 256 separate bars. At a typical plot width a bin is barely more than + // a pixel across, so bars land on fractional boundaries and cover one + // pixel centre or two depending on phase -- which beats into a moire + // pattern that moves as the window is resized. Trapezoids share their + // edges exactly, so every pixel column is covered once. + gGL.getTextureSlot(0)->unbind(); + gGL.color4fv(color.mV); + gGL.begin(LLRender::TRIANGLES); + for (size_t i = 0; i + 1 < outline.size(); ++i) + { + const F32 x0 = outline[i].mV[VX]; + const F32 x1 = outline[i + 1].mV[VX]; + const F32 y0 = outline[i].mV[VY]; + const F32 y1 = outline[i + 1].mV[VY]; + + gGL.vertex2f(x0, base); + gGL.vertex2f(x1, base); + gGL.vertex2f(x1, y1); + + gGL.vertex2f(x0, base); + gGL.vertex2f(x1, y1); + gGL.vertex2f(x0, y0); + } + gGL.end(); + gGL.flush(); + } + + // The fill's upper edge is the one part of it that is not axis-aligned, so + // it gets the anti-aliased ribbon over the top. In outline mode this is + // the whole plot. + LLColor4 edge(color); + edge.mV[VALPHA] = filled ? llmin(1.f, color.mV[VALPHA] * 1.6f) : color.mV[VALPHA]; + gl_polyline_2d(outline, edge, filled ? 1.2f : 1.6f); +} + +void ALFloaterScopes::drawHistogram(const ALScopeData& data, EMode mode, const LLRect& plot) const +{ + gl_rect_2d(plot, LLUIColorTable::instance().getColor("MenuDefaultBgColor").get(), true); + + // Quarter-tone guides. Photographers reach for these constantly -- "is + // anything in the top quarter?" is most of what a histogram is asked. + const LLColor4 grid = LLUIColorTable::instance().getColor("DefaultShadowDark").get(); + for (S32 i = 1; i < 4; ++i) + { + const S32 x = plot.mLeft + plot.getWidth() * i / 4; + gl_line_2d(x, plot.mBottom, x, plot.mTop, grid); + } + + { + LLLocalClipRect clip(plot); + switch (mode) + { + case MODE_RGB: + // Additive so overlapping channels brighten towards white where + // they agree, which is exactly the neutral-grey reading you want. + for (S32 c = ALScopeData::CH_RED; c <= ALScopeData::CH_BLUE; ++c) + { + drawChannel(data, (ALScopeData::EChannel)c, CHANNEL_COLOR[c], plot, true); + } + drawChannel(data, ALScopeData::CH_LUMA, LLColor4(1.f, 1.f, 1.f, 0.55f), plot, false); + break; + case MODE_LUMA: + drawChannel(data, ALScopeData::CH_LUMA, CHANNEL_COLOR[ALScopeData::CH_LUMA], plot, true); + break; + case MODE_RED: + case MODE_GREEN: + case MODE_BLUE: + { + const auto ch = (ALScopeData::EChannel)(mode - MODE_RED); + drawChannel(data, ch, CHANNEL_COLOR[ch], plot, true); + break; + } + default: + break; + } + } + + gl_rect_2d(plot, LLUIColorTable::instance().getColor("DefaultShadowLight").get(), false); +} + +void ALFloaterScopes::drawWaveChannel(const ALScopeData& data, ALScopeData::EChannel channel, + const LLColor4& color, const LLRect& plot) const +{ + const F32 peak = data.getWavePeak(channel); + if (peak <= 0.f || plot.getWidth() <= 0 || plot.getHeight() <= 0) + { + return; + } + + const F32 cell_w = (F32)plot.getWidth() / (F32)ALScopeData::WAVE_COLUMNS; + const F32 cell_h = (F32)plot.getHeight() / (F32)ALScopeData::WAVE_LEVELS; + + // Below this a cell cannot change an 8-bit pixel, so drawing it costs six + // vertices to composite nothing. Most of a waveform's grid is this: a + // typical frame lights well under half of any column. + constexpr F32 MIN_VISIBLE = 1.f / 255.f; + + gGL.getTextureSlot(0)->unbind(); + gGL.begin(LLRender::TRIANGLES); + for (S32 column = 0; column < ALScopeData::WAVE_COLUMNS; ++column) + { + const F32 x0 = (F32)plot.mLeft + (F32)column * cell_w; + const F32 x1 = x0 + cell_w; + + // Levels are contiguous within a column, which is the order this walks + // them in -- see ALScopeData::waveIndex. + for (S32 level = 0; level < ALScopeData::WAVE_LEVELS; ++level) + { + const F32 share = data.getWaveCell(channel, column, level); + if (share <= 0.f) + { + continue; + } + + // Same curve the histogram's bars use, so the log toggle means the + // same thing in both and a trace does not change character when + // you switch modes. + const F32 intensity = barHeight(share, peak) * color.mV[VALPHA]; + if (intensity < MIN_VISIBLE) + { + continue; + } + + const F32 y0 = (F32)plot.mBottom + (F32)level * cell_h; + const F32 y1 = y0 + cell_h; + + gGL.color4f(color.mV[VRED], color.mV[VGREEN], color.mV[VBLUE], intensity); + gGL.vertex2f(x0, y0); + gGL.vertex2f(x1, y0); + gGL.vertex2f(x1, y1); + + gGL.vertex2f(x0, y0); + gGL.vertex2f(x1, y1); + gGL.vertex2f(x0, y1); + } + } + gGL.end(); + gGL.flush(); +} + +void ALFloaterScopes::drawWaveform(const ALScopeData& data, EMode mode, const LLRect& panel) const +{ + gl_rect_2d(panel, LLUIColorTable::instance().getColor("MenuDefaultBgColor").get(), true); + + // Guides run horizontally here, not vertically as on the histogram: on a + // waveform the level is the vertical axis, so "is anything in the top + // quarter" is a question about height. + const LLColor4 grid = LLUIColorTable::instance().getColor("DefaultShadowDark").get(); + for (S32 i = 1; i < 4; ++i) + { + const S32 y = panel.mBottom + panel.getHeight() * i / 4; + gl_line_2d(panel.mLeft, y, panel.mRight, y, grid); + } + + { + LLLocalClipRect clip(panel); + switch (mode) + { + case MODE_PARADE: + { + // Three plots side by side. A parade is read by comparing the + // heights of the three traces, so they need separate horizontal + // space -- overlaid, a cast just looks like a thicker trace. + constexpr S32 GAP = 3; + const S32 width = (panel.getWidth() - 2 * GAP) / 3; + for (S32 c = 0; c < 3; ++c) + { + const S32 left = panel.mLeft + c * (width + GAP); + const LLRect sub(left, panel.mTop, left + width, panel.mBottom); + + // Opaque: side by side there is nothing to see through, and the + // shared alpha would only make all three dimmer. + LLColor4 solid(CHANNEL_COLOR[c]); + solid.mV[VALPHA] = 1.f; + drawWaveChannel(data, (ALScopeData::EChannel)c, solid, sub); + + gl_rect_2d(sub, grid, false); + } + break; + } + case MODE_WAVE_RGB: + for (S32 c = ALScopeData::CH_RED; c <= ALScopeData::CH_BLUE; ++c) + { + drawWaveChannel(data, (ALScopeData::EChannel)c, CHANNEL_COLOR[c], panel); + } + break; + case MODE_WAVE_LUMA: + default: + drawWaveChannel(data, ALScopeData::CH_LUMA, CHANNEL_COLOR[ALScopeData::CH_LUMA], panel); + break; + } + } + + gl_rect_2d(panel, LLUIColorTable::instance().getColor("DefaultShadowLight").get(), false); +} + +void ALFloaterScopes::updateReadouts(const ALScopeData& data) +{ + if (!mClipReadout) + { + return; + } + + if (data.isEmpty()) + { + mClipReadout->setText(getString("waiting")); + return; + } + + // Report the worst channel: one blown channel is a clipped pixel even when + // the other two have headroom, and averaging would hide it. + F32 low = 0.f; + F32 high = 0.f; + for (S32 c = ALScopeData::CH_RED; c <= ALScopeData::CH_BLUE; ++c) + { + const auto ch = (ALScopeData::EChannel)c; + low = llmax(low, data.getClippedLow(ch)); + high = llmax(high, data.getClippedHigh(ch)); + } + + LLStringUtil::format_map_t args; + args["[LOW]"] = llformat("%.2f", low * 100.f); + args["[HIGH]"] = llformat("%.2f", high * 100.f); + + // Reading the value under the cursor is most of what a scope gets asked + // once the shape of the plot is familiar -- "is that 235 or 255?" -- and it + // comes free: the sample captureScopeSample already took is still in + // memory, so this is a lookup rather than a readback. + // + // It replaces the sample count rather than joining it. The count is + // reassurance you read once; the pixel is the answer you came for, and the + // line is not wide enough for both. + LLColor4U pixel; + if (gPipeline.getScopePixel(gViewerWindow->getCurrentMouseX(), gViewerWindow->getCurrentMouseY(), pixel)) + { + args["[R]"] = llformat("%3d", pixel.mV[VRED]); + args["[G]"] = llformat("%3d", pixel.mV[VGREEN]); + args["[B]"] = llformat("%3d", pixel.mV[VBLUE]); + mClipReadout->setText(getString("clipping_pixel", args)); + return; + } + + args["[SAMPLES]"] = llformat("%d", data.getSampleCount()); + mClipReadout->setText(getString("clipping", args)); +} + +namespace +{ +/// Colour for a vectorscope cell: the hue of its own direction, saturated by +/// its distance from neutral. +/// +/// Both are fixed by the cell's index, so this is built once. Deriving it per +/// frame cost three trig calls for each of up to 4096 cells, every frame the +/// window was open, to arrive at the same answer each time. +/// +/// Saturating by radius is not just decoration. Drawn at full chroma +/// regardless of distance, the middle of the scope -- where a well balanced +/// frame puts nearly all of its pixels -- came out as a faint rainbow rather +/// than the neutral grey it actually is. +const LLColor4& cellTint(S32 u, S32 v) +{ + static std::vector table; + if (table.empty()) + { + table.reserve(ALScopeData::CHROMA_SIZE * ALScopeData::CHROMA_SIZE); + for (S32 i = 0; i < ALScopeData::CHROMA_SIZE; ++i) + { + for (S32 j = 0; j < ALScopeData::CHROMA_SIZE; ++j) + { + const F32 su = ALScopeData::chromaCellCentre(i); + const F32 sv = ALScopeData::chromaCellCentre(j); + const F32 radius = llmin(1.f, sqrtf(su * su + sv * sv)); + + // Through the wheels' own basis, so the trace and a wheel puck + // agree about where a hue lives. + const LLVector3 dir = ALColorWheelModel::chromaDirection(atan2f(sv, su)); + table.emplace_back(0.5f + dir.mV[VX] * 0.6f * radius, + 0.5f + dir.mV[VY] * 0.6f * radius, + 0.5f + dir.mV[VZ] * 0.6f * radius, + 1.f); + } + } + } + return table[u * ALScopeData::CHROMA_SIZE + v]; +} +} // namespace + +void ALFloaterScopes::drawVectorscope(const ALScopeData& data, const LLRect& panel) const +{ + gl_rect_2d(panel, LLUIColorTable::instance().getColor("MenuDefaultBgColor").get(), true); + + // Square and centred. The chroma plane is isotropic -- a hue is a + // direction -- so stretching it to a wide panel would make some hues look + // more saturated than others at the same distance from neutral. + const S32 side = llmin(panel.getWidth(), panel.getHeight()); + const S32 left = panel.mLeft + (panel.getWidth() - side) / 2; + const S32 bottom = panel.mBottom + (panel.getHeight() - side) / 2; + const LLRect plot(left, bottom + side, left + side, bottom); + const F32 cx = (F32)plot.mLeft + (F32)side * 0.5f; + const F32 cy = (F32)plot.mBottom + (F32)side * 0.5f; + const F32 radius = (F32)side * 0.5f; + + const LLColor4 grid = LLUIColorTable::instance().getColor("DefaultShadowDark").get(); + + // Graticule: rings at a third and two thirds of full chroma, and spokes on + // the six primaries and secondaries -- the targets a colourist actually + // reads a vectorscope against. + for (S32 ring = 1; ring <= 3; ++ring) + { + std::vector circle; + constexpr S32 STEPS = 48; + circle.reserve(STEPS); + const F32 r = radius * (F32)ring / 3.f; + for (S32 i = 0; i < STEPS; ++i) + { + const F32 a = F_TWO_PI * (F32)i / (F32)STEPS; + circle.emplace_back(cx + cosf(a) * r, cy + sinf(a) * r); + } + gl_polyline_2d(circle, grid, 1.f, true); + } + for (S32 spoke = 0; spoke < 6; ++spoke) + { + // Derived from the same basis the wheels use, so a spoke labelled red + // points where a red cast actually lands. + const F32 a = F_TWO_PI * (F32)spoke / 6.f; + const std::vector line = { + LLVector2(cx, cy), + LLVector2(cx + cosf(a) * radius, cy + sinf(a) * radius) }; + gl_polyline_2d(line, grid, 1.f); + } + + { + LLLocalClipRect clip(plot); + + const F32 peak = data.getChromaPeak(); + if (peak > 0.f) + { + // Each cell is drawn in the colour it represents, at full chroma + // for the hue and a brightness that follows how much of the frame + // sits there. A trace is then legible as itself: a warm sky reads + // as an orange smear at orange, not as a grey blob you have to + // measure against the graticule. + const F32 cell = (F32)side / (F32)ALScopeData::CHROMA_SIZE; + gGL.getTextureSlot(0)->unbind(); + gGL.begin(LLRender::TRIANGLES); + for (S32 u = 0; u < ALScopeData::CHROMA_SIZE; ++u) + { + for (S32 v = 0; v < ALScopeData::CHROMA_SIZE; ++v) + { + const F32 share = data.getChromaCell(u, v); + if (share <= 0.f) + { + continue; + } + + // The same curve the histogram offers, and for the same + // reason: a frame is mostly near-neutral, so on a linear + // scale the centre saturates and everything a colourist is + // looking for disappears into the background. + const F32 level = barHeight(share, peak); + + const F32 su = ALScopeData::chromaCellCentre(u); + const F32 sv = ALScopeData::chromaCellCentre(v); + const F32 x0 = cx + su * radius - cell * 0.5f; + const F32 y0 = cy + sv * radius - cell * 0.5f; + const F32 x1 = x0 + cell; + const F32 y1 = y0 + cell; + + LLColor4 tint = cellTint(u, v); + tint.mV[VALPHA] = llclamp(level, 0.f, 1.f); + + gGL.color4fv(tint.mV); + gGL.vertex2f(x0, y0); + gGL.vertex2f(x1, y0); + gGL.vertex2f(x1, y1); + + gGL.vertex2f(x0, y0); + gGL.vertex2f(x1, y1); + gGL.vertex2f(x0, y1); + } + } + gGL.end(); + gGL.flush(); + } + } + + gl_rect_2d(panel, LLUIColorTable::instance().getColor("DefaultShadowLight").get(), false); +} + +void ALFloaterScopes::drawScope(const ALScopeData& data, EMode mode, const LLRect& rect) const +{ + if (rect.getWidth() <= 0 || rect.getHeight() <= 0) + { + return; + } + + if (mode == MODE_VECTOR) + { + drawVectorscope(data, rect); + } + else if (isWaveMode(mode)) + { + drawWaveform(data, mode, rect); + } + else + { + drawHistogram(data, mode, rect); + } +} + +void ALFloaterScopes::drawPaneLabel(EMode mode, const LLRect& rect) const +{ + // Only when there is more than one pane. With a single scope filling the + // window the layout combo already says what it is, and the label would be + // ink on the plot for nothing. + if (getPaneCount() < 2) + { + return; + } + + const LLFontGL* font = LLFontGL::getFontSansSerifSmall(); + if (!font) + { + return; + } + + // Dim rather than full strength: this identifies the pane, it is not part + // of the measurement, and a bright label competes with the trace. + LLColor4 color = LLUIColorTable::instance().getColor("TextFgReadOnlyColor").get(); + color.mV[VALPHA] = 0.65f; + + // Ellipsised to the pane rather than clipped to it. These names are + // translated, and a longer language would otherwise run a label out of its + // own pane and across the trace in the one beside it. + font->renderUTF8(getString(modeStringName(mode)), 0, + (F32)(rect.mLeft + 4), (F32)(rect.mTop - 2), + color, LLFontGL::LEFT, LLFontGL::TOP, LLFontGL::NORMAL, + LLFontGL::DROP_SHADOW_SOFT, + S32_MAX, rect.getWidth() - 8, nullptr, true); +} + +bool ALFloaterScopes::handleRightMouseDown(S32 x, S32 y, MASK mask) +{ + const S32 pane = paneAt(x, y); + if (pane < 0) + { + return LLFloater::handleRightMouseDown(x, y, mask); + } + + auto* menu = static_cast(mPopupMenuHandle.get()); + if (!menu) + { + return LLFloater::handleRightMouseDown(x, y, mask); + } + + mMenuPane = pane; + menu->buildDrawLabels(); + menu->updateParent(LLMenuGL::sMenuContainer); + + // LLContextMenu::show, not LLMenuGL::showPopup. LLContextMenu overrides + // setVisible to ignore anything but false ("can't set visibility directly, + // must call show or hide"), and showPopup's only attempt to reveal a menu + // is setVisible(true) -- so it silently does nothing here. Everything else + // showPopup would have done, show() does for itself. + // + // show() takes screen coordinates; a mouse handler is given coordinates + // local to the view it landed on. + S32 screen_x, screen_y; + localPointToScreen(x, y, &screen_x, &screen_y); + menu->show(screen_x, screen_y, this); + return true; +} + +void ALFloaterScopes::onPaneModePicked(const LLSD& userdata) +{ + setPaneMode(mMenuPane, (EMode)userdata.asInteger()); +} + +bool ALFloaterScopes::isPaneModeChecked(const LLSD& userdata) const +{ + return mMenuPane >= 0 && getPaneMode(mMenuPane) == (EMode)userdata.asInteger(); +} + +void ALFloaterScopes::draw() +{ + if (mPlotPanel) + { + updateReadouts(gPipeline.getScopeData()); + } + + // Base first. Unlike a panel, a floater paints its own background in + // draw(), so a plot drawn before this would simply be painted over. + LLFloater::draw(); + + if (mPlotPanel) + { + const ALScopeData& data = gPipeline.getScopeData(); + + LLRect rects[MAX_PANES]; + const S32 count = computePaneRects(rects); + for (S32 i = 0; i < count; ++i) + { + const EMode mode = getPaneMode(i); + drawScope(data, mode, rects[i]); + drawPaneLabel(mode, rects[i]); + } + } +} diff --git a/indra/newview/alfloaterscopes.h b/indra/newview/alfloaterscopes.h new file mode 100644 index 0000000000..43c4a865c4 --- /dev/null +++ b/indra/newview/alfloaterscopes.h @@ -0,0 +1,183 @@ +/** + * @file alfloaterscopes.h + * @brief Histogram of the frame the viewer is presenting + * + * $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$ + */ + +#ifndef AL_FLOATERSCOPES_H +#define AL_FLOATERSCOPES_H + +#include "alscopedata.h" +#include "llfloater.h" + +class LLComboBox; +class LLTextBox; + +/** + * A scope for the frame, in its own window so it can sit beside the Lightbox + * while you drag its sliders. Putting it in a Lightbox tab would have hidden + * it behind whichever tab you were editing, which is the one thing a scope + * must never be. + * + * @par Where the numbers come from + * @ref LLPipeline::captureScopeSample point-decimates the buffer the frame is + * about to be presented from -- after tonemapping, grading, anti-aliasing and + * depth of field, before the vignette, grain and dither the final blit adds. + * Those last are print effects applied to a finished image; measuring through + * them would report the frame plus the paper it was printed on. + * + * The capture is gated on this floater existing: opening it sets + * @c LLPipeline::sScopeCapture and closing it clears it, so a viewer with the + * window shut does no sampling, no read-back and no binning at all. + * + * Drawing follows ALPanelMusicTicker's oscilloscope: a named child panel + * reserves the area in XUI and the floater draws the plot into its rect. A + * bespoke widget would have bought nothing here -- there is one consumer and + * it is this file. + * + * @par Panes + * That one reserved rect is then divided into up to four panes, each showing + * whichever scope it has been assigned. The scopes answer different questions + * about the same frame -- how much, where, what colour -- so the useful thing + * is to see several at once rather than to cycle between them. + * + * This costs nothing to measure. @ref ALScopeData::accumulate fills every + * channel, the chroma grid and the waveform grid on each capture whatever is + * on screen, so a fourth pane adds drawing and nothing else. Drawing is not + * free though, and it is lopsided: a histogram is 256 bins, but a waveform is + * @c WAVE_COLUMNS x @c WAVE_LEVELS cells *per channel*, so a pane showing a + * parade does roughly two hundred times the work of one showing a histogram. + * That is the reason the layout tops out at four. + */ +class ALFloaterScopes final : public LLFloater +{ +public: + ALFloaterScopes(const LLSD& key); + ~ALFloaterScopes() override; + + bool postBuild() override; + void onOpen(const LLSD& key) override; + void onClose(bool app_quitting) override; + void draw() override; + bool handleRightMouseDown(S32 x, S32 y, MASK mask) override; + +private: + /// What the plot shows: RGB overlaid, one channel on its own, or the + /// chroma plane. + enum EMode + { + MODE_RGB = 0, + MODE_LUMA, + MODE_RED, + MODE_GREEN, + MODE_BLUE, + /// Not a histogram at all. Kept in the same list because it answers + /// the same question from the other side: a histogram says how bright, + /// a vectorscope says what colour. + MODE_VECTOR, + /// And these say *where*. A histogram counts the whole frame into one + /// distribution, so a blown sky and a blown face are the same bin; a + /// waveform keeps the frame's own x axis and separates them. + MODE_WAVE_LUMA, + MODE_WAVE_RGB, + /// The three channels side by side rather than overlaid, which is how + /// a cast is read: the traces sit at visibly different heights. + MODE_PARADE, + MODE_COUNT, + }; + + /// How the plot area is divided. The order is the combo's order, and the + /// values are persisted, so append rather than insert. + enum ELayout + { + LAYOUT_SINGLE = 0, + LAYOUT_COLUMNS, + LAYOUT_ROWS, + LAYOUT_QUAD, + LAYOUT_COUNT, + }; + + /// Four is a deliberate ceiling, not a spare-capacity number -- see the + /// class comment on what a parade pane costs to draw. + static constexpr S32 MAX_PANES = 4; + + ELayout getLayout() const; + /// Panes the current layout shows, 1 to MAX_PANES. + S32 getPaneCount() const; + /// The scope assigned to @a pane. Out-of-range panes read MODE_RGB rather + /// than assert: a stale setting must not be able to crash the floater. + EMode getPaneMode(S32 pane) const; + void setPaneMode(S32 pane, EMode mode); + + /// Divides the plot panel into the active layout's panes, in pane-index + /// order, and returns how many it wrote. Rects are in the floater's own + /// coordinate space, which is both what draw() paints in and what + /// handleRightMouseDown is given -- so the same rects hit-test and draw. + S32 computePaneRects(LLRect (&out)[MAX_PANES]) const; + /// Index of the pane containing (@a x, @a y), or -1 for none. + S32 paneAt(S32 x, S32 y) const; + + /// XUI string name for a scope's display name, for the pane's corner label + /// and nothing else. Localisable because it is shown to the user. + static const char* modeStringName(EMode mode); + + bool useLogScale() const; + /// True for the modes drawn as a waveform rather than a histogram. + static bool isWaveMode(EMode mode); + + /// Draws @a mode into @a rect. The one place that knows which of the three + /// plot kinds a mode belongs to. + void drawScope(const ALScopeData& data, EMode mode, const LLRect& rect) const; + void drawPaneLabel(EMode mode, const LLRect& rect) const; + + void drawHistogram(const ALScopeData& data, EMode mode, const LLRect& plot) const; + void drawVectorscope(const ALScopeData& data, const LLRect& panel) const; + void drawWaveform(const ALScopeData& data, EMode mode, const LLRect& panel) const; + void drawChannel(const ALScopeData& data, ALScopeData::EChannel channel, + const LLColor4& color, const LLRect& plot, bool filled) const; + /// One channel's waveform into @a plot. Separate from drawChannel because + /// the two share nothing but a name: this walks a two-dimensional grid and + /// emits per-cell intensity, rather than one outline across the bins. + void drawWaveChannel(const ALScopeData& data, ALScopeData::EChannel channel, + const LLColor4& color, const LLRect& plot) const; + void updateReadouts(const ALScopeData& data); + + /// Bin height as a fraction of the plot, honouring the scale toggle. + /// A histogram is spiky; on a linear scale one dominant bin flattens + /// everything else into the axis, which is why photo tools offer both. + F32 barHeight(F32 share, F32 peak) const; + + /// Right-click assigns a scope to a pane, so the menu needs to know which + /// pane it was opened over. Set by handleRightMouseDown before the menu is + /// shown, and read by the menu's callbacks. + void onPaneModePicked(const LLSD& userdata); + bool isPaneModeChecked(const LLSD& userdata) const; + + LLPanel* mPlotPanel = nullptr; + LLComboBox* mLayoutCombo = nullptr; + LLTextBox* mClipReadout = nullptr; + + LLHandle mPopupMenuHandle; + /// Which pane the open context menu is acting on; -1 when none is. + S32 mMenuPane = -1; +}; + +#endif // AL_FLOATERSCOPES_H diff --git a/indra/newview/algradehistory.cpp b/indra/newview/algradehistory.cpp new file mode 100644 index 0000000000..3f6ca3384b --- /dev/null +++ b/indra/newview/algradehistory.cpp @@ -0,0 +1,180 @@ +/** + * @file algradehistory.cpp + * @brief Undo/redo for the Lightbox's settings, as transactions + * + * $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$ + */ + +#include "linden_common.h" + +#include "algradehistory.h" + +#include + +bool ALGradeHistory::canCoalesce(const std::string& name, F32 now) const +{ + // Only ever into the newest step, and only when that step is a lone write + // to this same control. A transaction that already covers two controls was + // a deliberate group, and swallowing a stray write into it would make the + // group mean something its author did not intend. + return mHaveLast + && mGroupDepth == 0 + && mCursor == mStack.size() + && !mStack.empty() + && mStack.back().size() == 1 + && mLastName == name + && (now - mLastTime) <= COALESCE_SECONDS; +} + +void ALGradeHistory::record(const std::string& name, const LLSD& before, const LLSD& after, F32 now) +{ + if (name.empty()) + { + return; + } + + // Anything recorded invalidates the redo tail: the future that was undone + // is no longer reachable from here. + if (mCursor < mStack.size()) + { + mStack.resize(mCursor); + } + + if (mGroupDepth > 0) + { + // Inside a group. Extend the group's transaction, unless this control + // is already in it -- in which case only the destination moves, so the + // group still describes one before and one after per control. + // + // MAX_DEPTH is deliberately not enforced here: evicting the front + // would shift mGroupIndex out from under the group. endGroup does it, + // once the index is dead. + if (mGroupIndex >= mStack.size()) + { + mStack.emplace_back(); + mGroupIndex = mStack.size() - 1; + } + + Transaction& group = mStack[mGroupIndex]; + auto existing = std::find_if(group.begin(), group.end(), + [&name](const Change& c) { return c.mName == name; }); + if (existing != group.end()) + { + existing->mAfter = after; + } + else + { + group.push_back({ name, before, after }); + } + + mCursor = mStack.size(); + mHaveLast = false; + return; + } + + if (canCoalesce(name, now)) + { + // Same control, still moving: keep the value it started from and let + // the destination follow. One drag stays one step. + mStack.back().front().mAfter = after; + } + else + { + mStack.push_back(Transaction{ { name, before, after } }); + + if (mStack.size() > MAX_DEPTH) + { + mStack.erase(mStack.begin()); + } + } + + mCursor = mStack.size(); + mLastName = name; + mLastTime = now; + mHaveLast = true; +} + +void ALGradeHistory::beginGroup() +{ + if (mGroupDepth++ == 0) + { + // One past the end: the transaction is created by the first write, so + // a group that records nothing leaves no empty step behind. + mGroupIndex = mStack.size(); + mHaveLast = false; + } +} + +void ALGradeHistory::endGroup() +{ + if (mGroupDepth > 0 && --mGroupDepth == 0) + { + // The eviction that record()'s plain path does as it pushes, deferred + // to here, where erasing the front can no longer shift mGroupIndex + // out from under an open group. A group adds at most one transaction + // -- that is its whole point -- so one erase restores the bound. The + // cursor counts applied transactions and the one dropped was applied, + // so it comes down with the stack. + if (mStack.size() > MAX_DEPTH) + { + mStack.erase(mStack.begin()); + if (mCursor > 0) + { + --mCursor; + } + } + + // A fresh write after the group starts its own step rather than + // coalescing into it. + mHaveLast = false; + } +} + +const ALGradeHistory::Transaction* ALGradeHistory::undo() +{ + if (!canUndo()) + { + return nullptr; + } + + // Applying the result must not fold back into the step it came from. + mHaveLast = false; + return &mStack[--mCursor]; +} + +const ALGradeHistory::Transaction* ALGradeHistory::redo() +{ + if (!canRedo()) + { + return nullptr; + } + + mHaveLast = false; + return &mStack[mCursor++]; +} + +void ALGradeHistory::clear() +{ + mStack.clear(); + mCursor = 0; + mGroupDepth = 0; + mGroupIndex = 0; + mHaveLast = false; +} diff --git a/indra/newview/algradehistory.h b/indra/newview/algradehistory.h new file mode 100644 index 0000000000..99671c1b4b --- /dev/null +++ b/indra/newview/algradehistory.h @@ -0,0 +1,127 @@ +/** + * @file algradehistory.h + * @brief Undo/redo for the Lightbox's settings, as transactions + * + * $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$ + */ + +#ifndef AL_GRADEHISTORY_H +#define AL_GRADEHISTORY_H + +#include "llsd.h" + +#include +#include + +/** + * The Lightbox's undo stack. + * + * @par Why transactions rather than single values + * "Undo" has to mean one thing the user did, and one thing the user did is + * often several settings. A section's Reset All writes eleven controls; undoing + * that eleven times would be absurd. So the unit here is a transaction -- a set + * of (control, before, after) -- and a group of writes can be marked as one. + * + * @par Why coalescing + * Dragging a wheel puck emits a commit per mouse-move. Recorded naively, one + * drag would be a hundred undo steps and Ctrl+Z would appear broken. Successive + * writes to the *same* control within a short window therefore extend the top + * transaction instead of pushing a new one: its `before` stays the value from + * the start of the drag, and its `after` follows the puck. + * + * @par No clock in here + * `record` takes the current time rather than reading one, so the coalescing + * window is exercised by the tests directly instead of by sleeping. + * + * @par What it deliberately does not do + * It stores values, not dirty state. Undoing restores what the settings were; + * it does not try to restore whether the active Look was considered modified. + * Reconstructing that faithfully would mean modelling the Looks system's own + * history as well, and the honest simple behaviour -- your values come back, + * the Look stays dirty -- is one a user can predict. + */ +class ALGradeHistory +{ +public: + struct Change + { + std::string mName; + LLSD mBefore; + LLSD mAfter; + }; + + using Transaction = std::vector; + + /// Writes to one control closer together than this join the previous step. + static constexpr F32 COALESCE_SECONDS = 0.5f; + + /// Steps kept. Beyond this the oldest is dropped: a grading session is long + /// and the values are small, but it should not grow without bound. + static constexpr size_t MAX_DEPTH = 64; + + /// Record one control changing from @a before to @a after at @a now + /// (seconds, monotonic; only differences matter). Recording anything + /// discards the redo tail, as every undo stack does. + void record(const std::string& name, const LLSD& before, const LLSD& after, F32 now); + + /// @name Grouping + /// Everything recorded between these becomes a single step, whatever the + /// controls or the timing -- for a section reset, or applying a Look. + /// Nesting is counted, so a group inside a group is still one step. + /// @{ + void beginGroup(); + void endGroup(); + /// @} + + bool canUndo() const { return mCursor > 0; } + bool canRedo() const { return mCursor < mStack.size(); } + + /// Step back one transaction and return it, or null if there is nothing to + /// undo. Apply each Change's @c mBefore, in any order -- a transaction + /// never contains the same control twice. + const Transaction* undo(); + + /// Step forward one transaction and return it, or null. Apply @c mAfter. + const Transaction* redo(); + + void clear(); + size_t depth() const { return mStack.size(); } + size_t cursor() const { return mCursor; } + +private: + /// True when the top transaction is a lone write to @a name that is recent + /// enough to absorb another. + bool canCoalesce(const std::string& name, F32 now) const; + + std::vector mStack; + + /// How many transactions are currently applied. Everything at or past this + /// index is redoable; everything before it is undoable. + size_t mCursor = 0; + + S32 mGroupDepth = 0; + /// Index of the transaction the current group is accumulating into. + size_t mGroupIndex = 0; + std::string mLastName; + F32 mLastTime = 0.f; + bool mHaveLast = false; +}; + +#endif // AL_GRADEHISTORY_H diff --git a/indra/newview/alscopedata.cpp b/indra/newview/alscopedata.cpp new file mode 100644 index 0000000000..73da0e4bf5 --- /dev/null +++ b/indra/newview/alscopedata.cpp @@ -0,0 +1,317 @@ +/** + * @file alscopedata.cpp + * @brief Tonal distribution of a sampled frame + * + * $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$ + */ + +#include "linden_common.h" + +#include "alscopedata.h" + +#include "alcolorwheelmodel.h" +#include "llmath.h" + +#include + +void ALScopeData::clear() +{ + for (S32 c = 0; c < CH_COUNT; ++c) + { + std::fill(std::begin(mBins[c]), std::end(mBins[c]), 0.f); + mPeak[c] = 0.f; + } + for (S32 u = 0; u < CHROMA_SIZE; ++u) + { + std::fill(std::begin(mChroma[u]), std::end(mChroma[u]), 0.f); + } + mChromaPeak = 0.f; + mSampleCount = 0; + + // Released rather than zeroed: the point of holding it in a vector is that + // a viewer whose scopes have never been opened carries none of it. + mWave.clear(); + mWave.shrink_to_fit(); + std::fill(std::begin(mWavePeak), std::end(mWavePeak), 0.f); +} + +F32 ALScopeData::getChromaCell(S32 u, S32 v) const +{ + if (u < 0 || u >= CHROMA_SIZE || v < 0 || v >= CHROMA_SIZE) + { + return 0.f; + } + return mChroma[u][v]; +} + +// static +F32 ALScopeData::chromaCellCentre(S32 index) +{ + // Cells span [-1, 1] in units of MAX_CHROMA; this is the middle of one. + return ((F32)index + 0.5f) * (2.f / (F32)CHROMA_SIZE) - 1.f; +} + +void ALScopeData::accumulate(const U8* rgba, S32 width, S32 height) +{ + if (!rgba || width <= 0 || height <= 0) + { + clear(); + return; + } + + const S32 pixel_count = width * height; + + // Count in integers first. A running float sum over tens of thousands of + // 1/N increments loses precision in exactly the tail bins that matter. + U32 counts[CH_COUNT][BIN_COUNT] = {}; + U32 chroma_counts[CHROMA_SIZE][CHROMA_SIZE] = {}; + + // On the heap, unlike the two above: this one is a quarter of a megabyte, + // and accumulate is called on a stack that is already carrying a whole + // ALScopeData. + std::vector wave_counts((size_t)CH_COUNT * WAVE_COLUMNS * WAVE_LEVELS, 0u); + + // Which wave column each x falls in. Precomputed because it depends only + // on x, and a divide per pixel over ~57k pixels is worth not doing. + std::vector column_of_x((size_t)width); + std::vector column_pixels((size_t)WAVE_COLUMNS, 0u); + for (S32 x = 0; x < width; ++x) + { + const S32 column = llmin(x * WAVE_COLUMNS / width, WAVE_COLUMNS - 1); + column_of_x[x] = column; + column_pixels[column] += (U32)height; + } + + // Cell index from a chroma coordinate: the inverse of chromaCellCentre, so + // a plot laid out with that function lands the trace where the binning put + // it rather than half a cell off. + const F32 chroma_to_cell = (F32)CHROMA_SIZE * 0.5f / ALColorWheelModel::MAX_CHROMA; + constexpr F32 INV_255 = 1.f / 255.f; + + // Walked as rows and columns rather than as a flat run, because the + // waveform needs to know which column a pixel came from. + for (S32 y = 0; y < height; ++y) + { + for (S32 x = 0; x < width; ++x) + { + const S32 i = y * width + x; + const U8 r = rgba[i * 4 + 0]; + const U8 g = rgba[i * 4 + 1]; + const U8 b = rgba[i * 4 + 2]; + + ++counts[CH_RED][r]; + ++counts[CH_GREEN][g]; + ++counts[CH_BLUE][b]; + + // Rec.709 in 16.16 fixed point, rounded. Integer arithmetic so the + // bin a given pixel lands in is exact and reproducible rather than + // depending on the compiler's float contraction. + const U32 luma = llmin((13933u * r + 46871u * g + 4732u * b + 32768u) >> 16, (U32)(BIN_COUNT - 1)); + ++counts[CH_LUMA][luma]; + + // Same four channels again, this time against the column. The + // divide is exact for any WAVE_LEVELS that divides 256. + const S32 column = column_of_x[x]; + ++wave_counts[waveIndex(CH_RED, column, r * WAVE_LEVELS / BIN_COUNT)]; + ++wave_counts[waveIndex(CH_GREEN, column, g * WAVE_LEVELS / BIN_COUNT)]; + ++wave_counts[waveIndex(CH_BLUE, column, b * WAVE_LEVELS / BIN_COUNT)]; + ++wave_counts[waveIndex(CH_LUMA, column, (S32)luma * WAVE_LEVELS / BIN_COUNT)]; + + // Chroma, on the wheels' own plane. A triplet's magnitude here + // cannot exceed MAX_CHROMA -- that is what MAX_CHROMA is -- so the + // clamp catches rounding at the cube's corners, not real data. + F32 cu, cv; + ALColorWheelModel::toChroma( + LLVector3((F32)r * INV_255, (F32)g * INV_255, (F32)b * INV_255), cu, cv); + const S32 ui = llclamp((S32)floorf(cu * chroma_to_cell) + CHROMA_SIZE / 2, 0, CHROMA_SIZE - 1); + const S32 vi = llclamp((S32)floorf(cv * chroma_to_cell) + CHROMA_SIZE / 2, 0, CHROMA_SIZE - 1); + ++chroma_counts[ui][vi]; + } + } + + const F32 inv = 1.f / (F32)pixel_count; + for (S32 c = 0; c < CH_COUNT; ++c) + { + F32 peak = 0.f; + for (S32 bin = 0; bin < BIN_COUNT; ++bin) + { + const F32 share = (F32)counts[c][bin] * inv; + mBins[c][bin] = share; + peak = llmax(peak, share); + } + mPeak[c] = peak; + } + + F32 chroma_peak = 0.f; + for (S32 u = 0; u < CHROMA_SIZE; ++u) + { + for (S32 v = 0; v < CHROMA_SIZE; ++v) + { + const F32 share = (F32)chroma_counts[u][v] * inv; + mChroma[u][v] = share; + chroma_peak = llmax(chroma_peak, share); + } + } + mChromaPeak = chroma_peak; + + // Per column, not per sample: a cell of 1.0 means "this whole column of the + // frame sits at this level", which reads the same whatever the frame's + // width was and is what makes the plot's intensity scale meaningful. + // + // Each column is divided by its *own* pixel count rather than by height. + // The sample's width rarely divides WAVE_COLUMNS evenly, so columns cover + // unequal numbers of source columns; and a sample narrower than the grid + // leaves some columns with no pixels at all, which must stay empty rather + // than divide by zero. + mWave.assign((size_t)CH_COUNT * WAVE_COLUMNS * WAVE_LEVELS, 0.f); + for (S32 c = 0; c < CH_COUNT; ++c) + { + F32 peak = 0.f; + for (S32 column = 0; column < WAVE_COLUMNS; ++column) + { + if (column_pixels[column] == 0u) + { + continue; + } + const F32 inv_column = 1.f / (F32)column_pixels[column]; + for (S32 level = 0; level < WAVE_LEVELS; ++level) + { + const size_t at = waveIndex(c, column, level); + const F32 share = (F32)wave_counts[at] * inv_column; + mWave[at] = share; + peak = llmax(peak, share); + } + } + mWavePeak[c] = peak; + } + + mSampleCount = pixel_count; +} + +void ALScopeData::blendToward(const ALScopeData& other, F32 alpha) +{ + alpha = llclamp(alpha, 0.f, 1.f); + + // Nothing measured here yet: a blend from zero would fade the first real + // sample in over several updates, which reads as the scope being broken. + if (isEmpty() || alpha >= 1.f) + { + *this = other; + return; + } + if (other.isEmpty() || alpha <= 0.f) + { + return; + } + + for (S32 c = 0; c < CH_COUNT; ++c) + { + F32 peak = 0.f; + for (S32 bin = 0; bin < BIN_COUNT; ++bin) + { + const F32 blended = mBins[c][bin] + (other.mBins[c][bin] - mBins[c][bin]) * alpha; + mBins[c][bin] = blended; + peak = llmax(peak, blended); + } + // Recomputed rather than blended: the peak has to be the actual + // maximum of the bins a plot will draw, or bars overflow the box. + mPeak[c] = peak; + } + + F32 chroma_peak = 0.f; + for (S32 u = 0; u < CHROMA_SIZE; ++u) + { + for (S32 v = 0; v < CHROMA_SIZE; ++v) + { + const F32 blended = mChroma[u][v] + (other.mChroma[u][v] - mChroma[u][v]) * alpha; + mChroma[u][v] = blended; + chroma_peak = llmax(chroma_peak, blended); + } + } + mChromaPeak = chroma_peak; + + // Shapes can differ: this one may never have measured a waveform, or the + // grid constants may have changed under a saved object. Take the other + // outright rather than blending mismatched grids. The emptiness test also + // keeps the fixed-size loop below off two empty-but-equal grids -- not + // reachable while accumulate always fills the waveform, but this must not + // depend on that. + if (mWave.size() != other.mWave.size() || other.mWave.empty()) + { + mWave = other.mWave; + std::copy(std::begin(other.mWavePeak), std::end(other.mWavePeak), std::begin(mWavePeak)); + } + else + { + for (S32 c = 0; c < CH_COUNT; ++c) + { + F32 peak = 0.f; + for (S32 i = 0; i < WAVE_COLUMNS * WAVE_LEVELS; ++i) + { + const size_t at = (size_t)c * WAVE_COLUMNS * WAVE_LEVELS + i; + const F32 blended = mWave[at] + (other.mWave[at] - mWave[at]) * alpha; + mWave[at] = blended; + peak = llmax(peak, blended); + } + mWavePeak[c] = peak; + } + } + + mSampleCount = other.mSampleCount; +} + +F32 ALScopeData::getWaveCell(EChannel channel, S32 column, S32 level) const +{ + if (!validChannel(channel) || mWave.empty() || + column < 0 || column >= WAVE_COLUMNS || level < 0 || level >= WAVE_LEVELS) + { + return 0.f; + } + return mWave[waveIndex(channel, column, level)]; +} + +F32 ALScopeData::getWavePeak(EChannel channel) const +{ + return validChannel(channel) ? mWavePeak[channel] : 0.f; +} + +F32 ALScopeData::getBin(EChannel channel, S32 bin) const +{ + if (!validChannel(channel) || bin < 0 || bin >= BIN_COUNT) + { + return 0.f; + } + return mBins[channel][bin]; +} + +F32 ALScopeData::getPeak(EChannel channel) const +{ + return validChannel(channel) ? mPeak[channel] : 0.f; +} + +F32 ALScopeData::getClippedLow(EChannel channel) const +{ + return getBin(channel, 0); +} + +F32 ALScopeData::getClippedHigh(EChannel channel) const +{ + return getBin(channel, BIN_COUNT - 1); +} diff --git a/indra/newview/alscopedata.h b/indra/newview/alscopedata.h new file mode 100644 index 0000000000..0332546440 --- /dev/null +++ b/indra/newview/alscopedata.h @@ -0,0 +1,187 @@ +/** + * @file alscopedata.h + * @brief Tonal distribution of a sampled frame: histogram bins and clipping + * + * $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$ + */ + +#ifndef AL_SCOPEDATA_H +#define AL_SCOPEDATA_H + +#include "stdtypes.h" + +#include + +/** + * The measured half of the scopes floater. No GL, no UI, no viewer globals -- + * pixels in, distribution out -- so @c alscopedata_test can exercise it. + * + * @par Why bins are fractions, not counts + * Every bin holds its share of the sample, not a raw count. Two consequences + * that both matter: the display scales the same whether the sample was + * 320x180 or something else, and two samples of different sizes can be blended + * against each other, which is what the temporal smoothing does. + * + * @par What "clipped" means here + * Exactly bin 0 and exactly bin 255. The source is an 8-bit read of the + * post-tonemap buffer, so a pixel that landed on either end is one the display + * cannot distinguish from a more extreme value -- which is the question a + * photographer is asking. It is a fraction of sampled pixels, and the sample + * is a point decimation of the frame, so it estimates the true fraction + * without bias. It is *not* a count of clipped pixels in the frame. + * + * @par Luma + * Rec.709 weights applied to the encoded (display-space) values, matching + * CG_LUMA in colorGradeUtilF.glsl and what photo tools show. Deliberately not + * a linearised luminance: the point of the readout is what the image looks + * like on the display, not what its radiometry was. + */ +class ALScopeData +{ +public: + static constexpr S32 BIN_COUNT = 256; + + enum EChannel + { + CH_RED = 0, + CH_GREEN, + CH_BLUE, + CH_LUMA, + CH_COUNT + }; + + ALScopeData() { clear(); } + + /// Drop every bin and the sample count. + void clear(); + + /// Bin a @a width by @a height block of RGBA8 pixels, replacing whatever + /// was here. Alpha is ignored. A null pointer or a non-positive dimension + /// clears instead. + /// + /// The dimensions are needed, rather than just a count, because the + /// waveform below is a per-column measurement: a flat array cannot say + /// which column a pixel came from. + void accumulate(const U8* rgba, S32 width, S32 height); + + /// Move every bin a fraction @a alpha of the way towards @a other, so a + /// noisy sample settles instead of flickering. @a alpha of 1 takes @a other + /// outright; 0 keeps this. Sample count and peaks follow the bins. + void blendToward(const ALScopeData& other, F32 alpha); + + /// Share of the sample in @a bin, 0..1. + F32 getBin(EChannel channel, S32 bin) const; + + /// The largest share any one bin holds, which is what a plot scales to. + /// Zero for an empty histogram, so callers must guard the division. + F32 getPeak(EChannel channel) const; + + /// Share of the sample sitting exactly on 0 or exactly on 255. + F32 getClippedLow(EChannel channel) const; + F32 getClippedHigh(EChannel channel) const; + + /// Pixels behind the current bins. Zero means nothing has been measured. + S32 getSampleCount() const { return mSampleCount; } + bool isEmpty() const { return mSampleCount <= 0; } + + /// @name Vectorscope + /// A second, two-dimensional histogram over the chroma plane -- the same + /// plane the colour wheels edit, via @c ALColorWheelModel::toChroma. That + /// is the point of it: push a wheel and the trace has to move the same + /// way, or the two readouts disagree about the same colour. + /// + /// Cells hold shares of the sample like the histogram's bins do, and blend + /// the same way, so the same reasoning about scale and smoothing applies. + /// @{ + + /// Cells per axis. Square, because the plane is isotropic -- unlike the + /// histogram, no axis here is special. + static constexpr S32 CHROMA_SIZE = 64; + + /// Share of the sample whose chroma falls in a cell. Out-of-range indices + /// read zero rather than assert: a plot walks the whole grid. + F32 getChromaCell(S32 u, S32 v) const; + + /// The largest share any one cell holds. Zero when nothing is measured, so + /// callers must guard the division. + F32 getChromaPeak() const { return mChromaPeak; } + + /// Chroma coordinate at the centre of cell @a index along either axis, + /// scaled so 1 is @c ALColorWheelModel::MAX_CHROMA. Keeps the plot's + /// geometry and the binning derived from one mapping instead of two. + static F32 chromaCellCentre(S32 index); + /// @} + + /// @name Waveform + /// One histogram per column of the frame: x is where across the image the + /// pixels came from, y is their code value. This is the question the tonal + /// histogram cannot answer -- a blown sky and a blown highlight on a face + /// are the same bin to a histogram, and obviously different here. + /// + /// Drawn per channel it is a waveform; drawn as R, G and B side by side it + /// is a parade, which is how a colourist reads a cast. + /// @{ + + /// Columns across the frame, and levels up it. Both are coarser than the + /// sample and than BIN_COUNT on purpose: this grid is two dimensional, so + /// its cost is the product, and at 128 x 128 x 4 channels it is already the + /// largest thing in the class. + static constexpr S32 WAVE_COLUMNS = 128; + static constexpr S32 WAVE_LEVELS = 128; + + /// Share of *that column's* pixels sitting at @a level, 0..1 -- not a share + /// of the whole sample, so a column reads the same however wide the frame + /// was. Out-of-range indices, and a scope with nothing measured, read zero + /// rather than assert: a plot walks the whole grid. + F32 getWaveCell(EChannel channel, S32 column, S32 level) const; + + /// Largest share any one cell of @a channel holds, which is what a plot + /// scales its intensity to. Zero when nothing is measured, so callers must + /// guard the division. + F32 getWavePeak(EChannel channel) const; + /// @} + +private: + static bool validChannel(EChannel channel) { return channel >= 0 && channel < CH_COUNT; } + + /// Flat index into mWave. Level varies fastest, so one column is + /// contiguous -- which is the order both the fill and the plot walk it in. + static size_t waveIndex(S32 channel, S32 column, S32 level) + { + return ((size_t)channel * WAVE_COLUMNS + column) * WAVE_LEVELS + level; + } + + F32 mBins[CH_COUNT][BIN_COUNT]; + F32 mPeak[CH_COUNT]; + F32 mChroma[CHROMA_SIZE][CHROMA_SIZE]; + F32 mChromaPeak; + S32 mSampleCount; + + /// CH_COUNT * WAVE_COLUMNS * WAVE_LEVELS floats -- a quarter of a megabyte, + /// which is why it is on the heap and not in the object. Two of these are + /// live at a time (the smoothed one and each fresh sample), and the fresh + /// one is a local, so as a member array it would put 256 KB on the stack + /// every capture. Empty until something is measured, so a viewer whose + /// scopes have never been opened pays nothing for it. + std::vector mWave; + F32 mWavePeak[CH_COUNT]; +}; + +#endif // AL_SCOPEDATA_H diff --git a/indra/newview/altoolscenepicker.cpp b/indra/newview/altoolscenepicker.cpp new file mode 100644 index 0000000000..ac83761d48 --- /dev/null +++ b/indra/newview/altoolscenepicker.cpp @@ -0,0 +1,85 @@ +/** + * @file altoolscenepicker.cpp + * @brief Transient tool that reports the linear scene colour under a click + * + * $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$ + */ + +#include "llviewerprecompiledheaders.h" + +#include "altoolscenepicker.h" + +#include "lltoolmgr.h" +#include "llviewerwindow.h" +#include "pipeline.h" + +ALToolScenePicker::ALToolScenePicker() +: LLTool(std::string("ScenePicker")) +{ +} + +ALToolScenePicker::~ALToolScenePicker() +{ +} + +void ALToolScenePicker::handleSelect() +{ + gViewerWindow->setCursor(UI_CURSOR_PIPETTE); +} + +void ALToolScenePicker::handleDeselect() +{ + // Dropped whether the pick completed or was cancelled. A callback that + // outlived its tool would fire into whatever floater came next. + mCallback = nullptr; + setMouseCapture(false); +} + +bool ALToolScenePicker::handleMouseDown(S32 x, S32 y, MASK mask) +{ + setMouseCapture(true); + return true; +} + +bool ALToolScenePicker::handleMouseUp(S32 x, S32 y, MASK mask) +{ + // Taken here rather than on the press so the aim can be corrected, and + // abandoned by releasing outside the world view. + pick_cb_t callback = mCallback; + + setMouseCapture(false); + + // Pops the tool, which runs handleDeselect and clears the callback -- so + // the copy above is the one that gets used, and the tool is already gone + // by the time the sample arrives a frame later. + LLToolMgr::getInstance()->clearTransientTool(); + + if (callback && gPipeline.isInit()) + { + gPipeline.requestScenePixel(x, y, std::move(callback)); + } + return true; +} + +bool ALToolScenePicker::handleHover(S32 x, S32 y, MASK mask) +{ + gViewerWindow->setCursor(UI_CURSOR_PIPETTE); + return true; +} diff --git a/indra/newview/altoolscenepicker.h b/indra/newview/altoolscenepicker.h new file mode 100644 index 0000000000..0039735239 --- /dev/null +++ b/indra/newview/altoolscenepicker.h @@ -0,0 +1,74 @@ +/** + * @file altoolscenepicker.h + * @brief Transient tool that reports the linear scene colour under a click + * + * $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$ + */ + +#ifndef AL_TOOLSCENEPICKER_H +#define AL_TOOLSCENEPICKER_H + +#include "lltool.h" +#include "llsingleton.h" +#include "v3color.h" + +#include + +/** + * Click anywhere in the world; get back the linear colour that was rendered + * there. + * + * Deliberately not @c LLToolPipette, which answers a different question: + * pipette reports the texture entry of the face you hit, so a white wall under + * a sunset reads as white. A colourist is asking about the light, so this + * reads the rendered pixel out of the scene buffer instead -- pre-grade and + * pre-tonemap, via @c LLPipeline::requestScenePixel. + * + * Transient, in the pipette's sense: install it with + * @c LLToolMgr::setTransientTool and it pops itself on mouse-up, so the tool + * that was in use comes back. It samples on mouse-UP rather than down, so a + * click can be aimed and then abandoned by dragging off first, and so the + * sample is never taken from a frame the press itself changed. + */ +class ALToolScenePicker +: public LLTool, public LLSingleton +{ + LLSINGLETON(ALToolScenePicker); + ~ALToolScenePicker() override; + +public: + typedef std::function pick_cb_t; + + /// Arm the tool. @a callback fires once, on the next frame after the + /// click, and is dropped if the pick is cancelled. + void setPickCallback(pick_cb_t callback) { mCallback = std::move(callback); } + + bool handleMouseDown(S32 x, S32 y, MASK mask) override; + bool handleMouseUp(S32 x, S32 y, MASK mask) override; + bool handleHover(S32 x, S32 y, MASK mask) override; + + void handleSelect() override; + void handleDeselect() override; + +private: + pick_cb_t mCallback; +}; + +#endif // AL_TOOLSCENEPICKER_H diff --git a/indra/newview/alwhitebalancesolver.cpp b/indra/newview/alwhitebalancesolver.cpp new file mode 100644 index 0000000000..67e8c91c47 --- /dev/null +++ b/indra/newview/alwhitebalancesolver.cpp @@ -0,0 +1,323 @@ +/** + * @file alwhitebalancesolver.cpp + * @brief The renderer's white-balance map, and its inverse + * + * $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$ + */ + +#include "linden_common.h" + +#include "alwhitebalancesolver.h" + +#include "llmath.h" + +#include + +namespace +{ + // Port of the Kim et al. 2002 Planckian-locus polynomial used by the + // original white-balance shader. Valid 1667K..25000K to ~1% of the true + // locus. Output is CIE xy chromaticity at Y = 1. + inline void cct_to_xy(F32 cct, F32& out_x, F32& out_y) + { + F32 t = cct, t2 = t * t, t3 = t2 * t; + if (t <= 4000.0f) + out_x = -0.2661239e9f / t3 - 0.2343589e6f / t2 + 0.8776956e3f / t + 0.179910f; + else + out_x = -3.0258469e9f / t3 + 2.1070379e6f / t2 + 0.2226347e3f / t + 0.240390f; + + F32 x2 = out_x * out_x, x3 = x2 * out_x; + if (t <= 2222.0f) + out_y = -1.1063814f * x3 - 1.34811020f * x2 + 2.18555832f * out_x - 0.20219683f; + else if (t <= 4000.0f) + out_y = -0.9549476f * x3 - 1.37418593f * x2 + 2.09137015f * out_x - 0.16748867f; + else + out_y = 3.0817580f * x3 - 5.87338670f * x2 + 3.75112997f * out_x - 0.37001483f; + } + + // Apply a Duv offset perpendicular to the Planckian locus via a central- + // difference tangent in CIE 1960 u,v space (O(h²) accurate). Returns the + // new CIE xy chromaticity. + inline void apply_duv(F32 cct, F32 duv, F32& out_x, F32& out_y) + { + F32 xA, yA, xB, yB; + cct_to_xy(cct - 1.0f, xA, yA); + cct_to_xy(cct + 1.0f, xB, yB); + + F32 dA = -2.0f * xA + 12.0f * yA + 3.0f; + F32 uA = 4.0f * xA / dA; + F32 vA = 6.0f * yA / dA; + F32 dB = -2.0f * xB + 12.0f * yB + 3.0f; + F32 uB = 4.0f * xB / dB; + F32 vB = 6.0f * yB / dB; + + F32 uMid = 0.5f * (uA + uB); + F32 vMid = 0.5f * (vA + vB); + F32 tx = uB - uA; + F32 ty = vB - vA; + F32 tlen = sqrtf(tx * tx + ty * ty); + if (tlen > 1e-12f) { tx /= tlen; ty /= tlen; } + + F32 u = uMid + (-ty) * duv; // perp = (-tangent.y, tangent.x) + F32 v = vMid + ( tx) * duv; + + F32 dBack = 2.0f * u - 8.0f * v + 4.0f; + out_x = 3.0f * u / dBack; + out_y = 2.0f * v / dBack; + } + + /// Log of a gain component, floored. Gains are ratios, so a factor-of-two + /// error matters as much at 0.5 as at 2 -- comparing them linearly would + /// weight cooling far more heavily than warming. + inline F32 log_gain(F32 g) + { + return logf(llmax(g, 1e-6f)); + } +} + +// static +LLVector3 ALWhiteBalanceSolver::gain(F32 cct_offset, F32 duv) +{ + // Resolve the artist's (CCT offset, Duv) pair into a linear-sRGB gain, + // normalised so green pins to 1 (preserves luminance). Duv sign is + // flipped to match the tint convention: +Duv pushes green, -Duv magenta. + const F32 cct_clamped = llclamp(cct_offset, CCT_MIN, CCT_MAX); + const F32 duv_uv = llclamp(duv, DUV_MIN, DUV_MAX) * DUV_UV_SCALE; + + if (fabsf(cct_clamped) < 1e-3f && fabsf(duv_uv) < 1e-5f) + return LLVector3(1.f, 1.f, 1.f); + + constexpr F32 D65_CCT = 6504.0f; + F32 target_cct = llclamp(D65_CCT + cct_clamped, 1667.0f, 25000.0f); + + F32 x, y; + apply_duv(target_cct, -duv_uv, x, y); + + F32 X = x / y; + F32 Y = 1.0f; + F32 Z = (1.0f - x - y) / y; + + F32 r = 3.2404542f * X - 1.5371385f * Y - 0.4985314f * Z; + F32 g = -0.9692660f * X + 1.8760108f * Y + 0.0415560f * Z; + F32 b = 0.0556434f * X - 0.2040259f * Y + 1.0572252f * Z; + + F32 inv_g = 1.0f / llmax(g, 1e-6f); + return LLVector3(r * inv_g, 1.0f, b * inv_g); +} + +// static +LLVector3 ALWhiteBalanceSolver::neutralisingGain(const LLColor3& color) +{ + const F32 r = llmax(color.mV[0], 1e-6f); + const F32 g = llmax(color.mV[1], 1e-6f); + const F32 b = llmax(color.mV[2], 1e-6f); + return LLVector3(g / r, 1.f, g / b); +} + +// static +bool ALWhiteBalanceSolver::isUsable(F32 cct_offset, F32 duv) +{ + const LLVector3 g = gain(cct_offset, duv); + return g.mV[VX] > 0.f && g.mV[VZ] > 0.f; +} + +// static +ALWhiteBalanceSolver::Result ALWhiteBalanceSolver::solve(const LLVector3& target_gain) +{ + const F32 target_r = log_gain(target_gain.mV[VX]); + const F32 target_b = log_gain(target_gain.mV[VZ]); + + // Squared error at a pair, and the two signed residuals it is made of. + // Out-of-gamut pairs are scored as unreachable rather than floored, + // because a floored channel compares equal to every other floored channel + // and the search would wander freely through the region instead of + // staying out of it. + auto residuals = [&](F32 cct, F32 duv, F32& out_r, F32& out_b) + { + const LLVector3 g = gain(cct, duv); + out_r = log_gain(g.mV[VX]) - target_r; + out_b = log_gain(g.mV[VZ]) - target_b; + }; + auto error_at = [&](F32 cct, F32 duv) + { + const LLVector3 g = gain(cct, duv); + if (g.mV[VX] <= 0.f || g.mV[VZ] <= 0.f) + { + return F32_MAX; + } + const F32 dr = log_gain(g.mV[VX]) - target_r; + const F32 db = log_gain(g.mV[VZ]) - target_b; + return dr * dr + db * db; + }; + + // Two stages, because neither alone is enough. + // + // The grid finds the right basin. Shrinking it repeatedly does not finish + // the job: at the warm end the error surface is a curved valley, so the + // winner of a coarse pass can sit more than one cell from the true + // minimum, and re-centring on it locks the answer out. That showed up as a + // ~0.07 residual around -3000K with a positive tint, which is a visible + // mis-balance, not a rounding error. + // + // Newton finishes it. Two residuals in two unknowns is a square system, so + // there is a Jacobian to invert rather than a landscape to search, and it + // converges to machine precision in a handful of steps. It needs a decent + // start, which is exactly what the grid provides. + constexpr S32 GRID = 10; + constexpr S32 GRID_PASSES = 3; + + F32 lo_cct = CCT_MIN, hi_cct = CCT_MAX; + F32 lo_duv = DUV_MIN, hi_duv = DUV_MAX; + + Result result; + + for (S32 pass = 0; pass < GRID_PASSES; ++pass) + { + F32 best_cct = result.mCCTOffset; + F32 best_duv = result.mDuv; + F32 best_err = F32_MAX; + + for (S32 i = 0; i <= GRID; ++i) + { + const F32 cct = lo_cct + (hi_cct - lo_cct) * (F32)i / (F32)GRID; + for (S32 j = 0; j <= GRID; ++j) + { + const F32 duv = lo_duv + (hi_duv - lo_duv) * (F32)j / (F32)GRID; + const F32 err = error_at(cct, duv); + if (err < best_err) + { + best_err = err; + best_cct = cct; + best_duv = duv; + } + } + } + + result.mCCTOffset = best_cct; + result.mDuv = best_duv; + + const F32 cct_step = (hi_cct - lo_cct) / (F32)GRID; + const F32 duv_step = (hi_duv - lo_duv) / (F32)GRID; + lo_cct = llmax(CCT_MIN, best_cct - cct_step); + hi_cct = llmin(CCT_MAX, best_cct + cct_step); + lo_duv = llmax(DUV_MIN, best_duv - duv_step); + hi_duv = llmin(DUV_MAX, best_duv + duv_step); + } + + // Central differences, with the span measured rather than assumed: against + // the edge of the box one side is clipped away, and dividing by the full + // 2h there would report a Jacobian half its true size. + // + // The spans are wide on purpose. A textbook-small step is wrong in single + // precision: over a Duv span of 2e-4 the gain moves by about 1e-4, which + // against a float32 epsilon of 1.2e-7 leaves roughly three significant + // digits of derivative -- enough for Newton to reach 1e-3 and stall there, + // which it did, a few hundred Kelvin from the warm end of the locus. These + // spans put four or five digits into the Jacobian instead. The map is + // smooth over them, and Newton tolerates an approximate Jacobian; it is + // the residual that has to be exact, and that is evaluated at a point. + constexpr F32 H_CCT = 25.f; + constexpr F32 H_DUV = 5e-3f; + constexpr S32 NEWTON_STEPS = 24; + + for (S32 step = 0; step < NEWTON_STEPS; ++step) + { + F32 r0, b0; + residuals(result.mCCTOffset, result.mDuv, r0, b0); + // Residuals of order 1e-6, which is about as close as single-precision + // gains get to each other. Chasing further only spins. + if (r0 * r0 + b0 * b0 < 1e-12f) + { + break; + } + + const F32 cct_hi = llmin(CCT_MAX, result.mCCTOffset + H_CCT); + const F32 cct_lo = llmax(CCT_MIN, result.mCCTOffset - H_CCT); + const F32 duv_hi = llmin(DUV_MAX, result.mDuv + H_DUV); + const F32 duv_lo = llmax(DUV_MIN, result.mDuv - H_DUV); + const F32 cct_span = cct_hi - cct_lo; + const F32 duv_span = duv_hi - duv_lo; + if (cct_span <= 0.f || duv_span <= 0.f) + { + break; + } + + // A probe that falls out of gamut would put a floored channel into the + // Jacobian and point the step somewhere meaningless. Near that edge + // the grid's answer, already within a cell, is the better one to keep. + if (!isUsable(cct_hi, result.mDuv) || !isUsable(cct_lo, result.mDuv) || + !isUsable(result.mCCTOffset, duv_hi) || !isUsable(result.mCCTOffset, duv_lo)) + { + break; + } + + F32 ra, ba, rb, bb; + residuals(cct_hi, result.mDuv, ra, ba); + residuals(cct_lo, result.mDuv, rb, bb); + const F32 j00 = (ra - rb) / cct_span; + const F32 j10 = (ba - bb) / cct_span; + + residuals(result.mCCTOffset, duv_hi, ra, ba); + residuals(result.mCCTOffset, duv_lo, rb, bb); + const F32 j01 = (ra - rb) / duv_span; + const F32 j11 = (ba - bb) / duv_span; + + const F32 det = j00 * j11 - j01 * j10; + if (fabsf(det) < 1e-18f) + { + // Singular: the two controls are momentarily pushing the gain the + // same way, so there is no unique step. The grid's answer stands. + break; + } + + // Bounded so a near-singular Jacobian cannot fling the guess across + // the box and lose the basin the grid just found. + const F32 step_cct = llclamp(-(j11 * r0 - j01 * b0) / det, -2000.f, 2000.f); + const F32 step_duv = llclamp(-(-j10 * r0 + j00 * b0) / det, -0.5f, 0.5f); + + const F32 here = error_at(result.mCCTOffset, result.mDuv); + F32 next_cct = llclamp(result.mCCTOffset + step_cct, CCT_MIN, CCT_MAX); + F32 next_duv = llclamp(result.mDuv + step_duv, DUV_MIN, DUV_MAX); + if (error_at(next_cct, next_duv) >= here) + { + // One backtrack. If half a step is no better either, this is the + // best the box holds -- which is the honest answer for a colour + // temperature and tint cannot reach. + next_cct = llclamp(result.mCCTOffset + step_cct * 0.5f, CCT_MIN, CCT_MAX); + next_duv = llclamp(result.mDuv + step_duv * 0.5f, DUV_MIN, DUV_MAX); + if (error_at(next_cct, next_duv) >= here) + { + break; + } + } + + result.mCCTOffset = next_cct; + result.mDuv = next_duv; + } + + result.mResidual = sqrtf(llmax(error_at(result.mCCTOffset, result.mDuv), 0.f) * 0.5f); + return result; +} + +// static +ALWhiteBalanceSolver::Result ALWhiteBalanceSolver::solveForColor(const LLColor3& color) +{ + return solve(neutralisingGain(color)); +} diff --git a/indra/newview/alwhitebalancesolver.h b/indra/newview/alwhitebalancesolver.h new file mode 100644 index 0000000000..b59238d871 --- /dev/null +++ b/indra/newview/alwhitebalancesolver.h @@ -0,0 +1,124 @@ +/** + * @file alwhitebalancesolver.h + * @brief The renderer's white-balance map, and its inverse + * + * $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$ + */ + +#ifndef AL_WHITEBALANCESOLVER_H +#define AL_WHITEBALANCESOLVER_H + +#include "v3color.h" +#include "v3math.h" + +/** + * Temperature and tint, both ways. + * + * @ref gain is the map pipeline.cpp uploads: an artist's (CCT offset, Duv) + * pair becomes the linear gain @c applyWhiteBalance multiplies by. It lives + * here rather than in pipeline.cpp so that the renderer and the eyedropper + * cannot drift apart -- the inverse is only worth anything if it inverts the + * function actually in use. + * + * @ref solve goes the other way: given the gain that would neutralise a + * sample, find the pair that produces it. That is the whole eyedropper. White + * balance is the first colour-affecting step in the chain (exposure precedes + * it, but scales all three channels alike), so a pixel read out of the linear + * scene buffer is exactly what the gain will be applied to. + * + * No inverse exists in closed form -- the forward map runs a Planckian-locus + * polynomial, a perpendicular offset in CIE 1960 uv, and an XYZ-to-sRGB + * matrix -- but it is smooth and two-dimensional over a bounded box, which a + * search handles comfortably. + */ +class ALWhiteBalanceSolver +{ +public: + /// The settings' own limits. The search uses them so a solution can never + /// come back outside what the sliders can express and then be silently + /// clamped into something the caller did not ask for. + static constexpr F32 CCT_MIN = -5000.f; + static constexpr F32 CCT_MAX = 5000.f; + static constexpr F32 DUV_MIN = -1.f; + static constexpr F32 DUV_MAX = 1.f; + + /// Duv is exposed to artists on a friendly [-1, 1] scale and is worth + /// +-0.02 in CIE 1960 uv. Everything public here speaks the artist's + /// units, so a solved pair can be written straight to the settings; the + /// conversion happens once, inside @ref gain. + /// + /// The whole [-1, 1] range matters: an unscaled Duv of 1 would be a + /// quarter of the way across the uv diagram, far outside any real + /// chromaticity, where the gain saturates and different pairs stop + /// producing different colours. A solver searching there finds a tie + /// rather than an answer. + static constexpr F32 DUV_UV_SCALE = 0.02f; + + struct Result + { + F32 mCCTOffset = 0.f; + F32 mDuv = 0.f; + /// RMS error between the gain this pair produces and the one asked + /// for, in log space. Zero for a colour that lies on the reachable + /// surface; large for one that does not -- a vivid green wall cannot + /// be neutralised by temperature and tint alone, and the caller is + /// better placed than the solver to decide what to say about that. + F32 mResidual = 0.f; + }; + + /// The renderer's forward map, in the settings' own units: @a cct_offset + /// in Kelvin from D65 and @a duv on the [-1, 1] tint scale. Both are + /// clamped to the range above, exactly as the shader upload used to clamp + /// them. Green is pinned to 1, so the gain changes colour without changing + /// luminance. + static LLVector3 gain(F32 cct_offset, F32 duv); + + /// The gain that would send @a color to grey: (g/r, 1, g/b), green pinned + /// the same way @ref gain pins it. + /// + /// Meaningless for a sample with no light in it -- the ratios of three + /// near-zero numbers are noise -- so callers should reject dark samples + /// before asking. Guarded against division by zero regardless. + static LLVector3 neutralisingGain(const LLColor3& color); + + /// Whether a pair yields a gain a renderer can use -- every component + /// positive. + /// + /// Below roughly 1900K the Planckian locus leaves the sRGB gamut and the + /// XYZ-to-sRGB matrix returns a negative blue, which is not a white + /// balance: multiplying by it flips the channel's sign. How far down that + /// starts depends on the tint, from about -4600 CCT offset at Duv 0 to + /// about -3460 at Duv +1. + /// + /// @ref solve never returns a pair that fails this, both because such a + /// gain is unusable and because it is not invertible: every negative blue + /// looks alike once floored, so the channel stops distinguishing one + /// candidate from another and the search has nothing to converge on. + static bool isUsable(F32 cct_offset, F32 duv); + + /// The pair whose gain best matches @a target_gain, searched over the + /// usable region only. + static Result solve(const LLVector3& target_gain); + + /// Sample in, settings out. + static Result solveForColor(const LLColor3& color); +}; + +#endif // AL_WHITEBALANCESOLVER_H diff --git a/indra/newview/app_settings/looks/Golden%20Hour.xml b/indra/newview/app_settings/looks/Golden%20Hour.xml new file mode 100644 index 0000000000..bd19ada4a7 --- /dev/null +++ b/indra/newview/app_settings/looks/Golden%20Hour.xml @@ -0,0 +1,1228 @@ + + + + AlchemyRenderTonemapType + + Comment + What tonemapper to use: 0 = Khronos Neutral, 1 = ACES Hill, 2 = ACES Boosted, 3 = Reinhard, 4 = Filmic, 5 = GT, 6 = AgX + Persist + 1 + Type + S32 + Value + 1 + + RenderBloomAlphaGlowBoost + + Comment + Multiplier applied to alpha-tagged glow (legacy prim glow parameter) when fed into HDR bloom. Compensates for pre-tonemap composite so prim-glow halos match legacy brightness. + Persist + 1 + Type + F32 + Value + 2.5 + + RenderBloomHalation + + Comment + Enable the halation signal in HDR bloom. When off, the bloom pyramid allocates as R11F_G11F_B10F (half the bandwidth of RGBA16F) and skips the warmth extract and tinted composite. Requires RenderBloomHDR. + Persist + 1 + Type + Boolean + Value + 0 + + RenderBloomHalationStrength + + Comment + Photographic halation (warm halo around bright highlights). 0 disables. 0.25 is a gentle cinematic feel. Requires RenderBloomHalation. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderBloomHalationTint + + Comment + Tint color for the halation halo (linear RGB). Default is a warm amber. Requires RenderBloomHalation. + Persist + 1 + Type + Color3 + Value + + 1.0 + 0.35 + 0.15 + + + RenderBloomKnee + + Comment + Soft-knee width below the bloom threshold. Wider values give a softer, more gradual bloom onset. Range 0 to 1. + Persist + 1 + Type + F32 + Value + 0.5 + + 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. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderBloomStrength + + Comment + HDR bloom composite strength. Multiplies the final bloom pyramid before it is additively blended into the scene. Independent of RenderGlowStrength so HDR bloom and legacy glow can be tuned separately. + Persist + 1 + Type + F32 + Value + 0.325 + + RenderBloomThreshold + + Comment + HDR bloom luminance threshold in linear light. Values above this bloom; below, fall off softly via the knee. 1.0 = roughly "brighter than white". + Persist + 1 + Type + F32 + Value + 2.5 + + RenderCASSharpness + + Comment + Level of sharpening to apply via Contrast Adaptive Sharpening (0.0(off) - 1.0) + Persist + 1 + Type + F32 + Value + 0.4 + + RenderChromaticAberrationAngle + + Comment + Rotates the red/blue split direction, in degrees. 0 is horizontal; 90 is vertical. Only meaningful when Anisotropy is nonzero. Range 0 to 360. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderChromaticAberrationAnisotropy + + Comment + Stretches the fringe along the angle axis. 0 gives an even radial fringe; positive values elongate it into a directional smear for anamorphic-style aberration. Range -1 to 1. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderChromaticAberrationFalloff + + Comment + How quickly the aberration grows from the screen center outward. 1 is a linear ramp; higher values keep the center clean and push fringing to the edges. Range 0.5 to 4. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderChromaticAberrationOffsetBX + + Comment + Blue channel horizontal offset direction. Typically opposite sign of OffsetRX for a standard red/blue split. Range -1 to 1. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderChromaticAberrationOffsetBY + + Comment + Blue channel vertical offset direction. Pairs with OffsetBX. 0 keeps blue split purely horizontal. Range -1 to 1. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderChromaticAberrationOffsetRX + + Comment + Red channel horizontal offset direction. -1 shifts red inward on the X axis, +1 outward. Combined with RY for per-channel fringe direction. Range -1 to 1. + Persist + 1 + Type + F32 + Value + -1.0 + + RenderChromaticAberrationOffsetRY + + Comment + Red channel vertical offset direction. Pairs with OffsetRX. 0 keeps red split purely horizontal. Range -1 to 1. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderChromaticAberrationStrength + + Comment + Overall intensity of the chromatic aberration fringe. 0 disables the effect. 0.15 gives a subtle cinema lens feel; 0.5 is heavy. Range 0 to 1. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderColorGrade + + Comment + Enable color grading + Persist + 1 + Type + Boolean + Value + 1 + + RenderColorGradeBlackPoint + + Comment + Crushes values at or below this level to pure black. 0 is identity, 0.05 deepens blacks cinematically, 0.1+ starts clipping shadow detail. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderColorGradeBrightness + + Comment + Shifts the whole image lighter or darker. Range -0.5 to 0.5; small values (±0.05) are already noticeable. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderColorGradeContrast + + Comment + S-curve around midgray. 1 is identity; less than 1 flattens (matte film look), greater than 1 punches (values above 1.3 start clipping). + Persist + 1 + Type + F32 + Value + 1.0 + + RenderColorGradeCurveShoulder + + Comment + Per-channel filmic curve shoulder point (RGB). Pixels at or above this input level flatten toward white. Lower values (around 0.85) give a tighter roll-off. + Persist + 1 + Type + Color3 + Value + + 1.0 + 1.0 + 1.0 + + + RenderColorGradeCurveStrength + + Comment + Per-channel curve blend strength (RGB). 0 disables; 0.4 is typical film, differing values per channel create cross-process looks. + Persist + 1 + Type + Color3 + Value + + 0.0 + 0.0 + 0.0 + + + RenderColorGradeCurveToe + + Comment + Per-channel filmic curve toe point (RGB). Pixels at or below this input level flatten toward black. Try (0.05, 0.05, 0.1) to lift shadow blues. + Persist + 1 + Type + Color3 + Value + + 0.0 + 0.0 + 0.0 + + + RenderColorGradeGain + + Comment + Highlight gain (R,G,B): multiplies the brightest tones to boost, clip, or tint whites. Per-channel range 0.5 to 1.5. Default (1,1,1). Try (1.05,1.02,0.95) warm highlights, (0.95,1.0,1.05) cool highlights, uniform <1 to tame blowouts. + Persist + 1 + Type + Vector3 + Value + + 1.0 + 1.0 + 1.0 + + + RenderColorGradeGamma + + Comment + Midtone gamma (R,G,B): bends the middle of the tonal range without moving blacks or whites. Above 1 brightens mids, below 1 darkens them. Per-channel range 0.5 to 1.5. Default (1,1,1). Try (1.1,1.1,1.1) open mids, (1.1,1.0,0.95) warm mids. + Persist + 1 + Type + Vector3 + Value + + 1.0 + 1.0 + 1.0 + + + RenderColorGradeHighlights + + Comment + Recovers (negative) or boosts (positive) highlights without touching midtones. Use -0.3 to pull blown skies back from clipping. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderColorGradeHueShift + + Comment + Rotates all hues around the color wheel in degrees. Range -180 to 180. 30 warms everything; 180 is a full complement (inverted). + Persist + 1 + Type + F32 + Value + 0.0 + + RenderColorGradeLUT + + Comment + Name of image file for color grading LUT(TGA, PNG, or WebP) + Persist + 1 + Type + String + Value + + + RenderColorGradeLUTStrength + + Comment + Strength of the color grading LUT effect + Persist + 1 + Type + F32 + Value + 1.0 + + RenderColorGradeLift + + Comment + Shadow lift (R,G,B): adds to the darkest tones to raise the black floor and tint shadows. Per-channel range -0.5 to +0.5. Default (0,0,0). Try (0.02,0.02,0.05) cool shadow mist, (0.05,0.03,0) warm lift, negatives crush toward black. + Persist + 1 + Type + Vector3 + Value + + 0.0 + 0.0 + 0.0 + + + RenderColorGradeSaturation + + Comment + Uniform saturation. 1 is identity, 0 is black-and-white, 1.2 is punchy, 1.5 is heavily stylized. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderColorGradeShadows + + Comment + Lifts (positive) or crushes (negative) shadows without touching midtones. Use +0.2 to reveal shadow detail in dark scenes. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderColorGradeVibrance + + Comment + Smart saturation that pushes muted colors harder than already-saturated ones. Skin-friendly; range -1 to 1, 0.3 is a subtle lift. + Persist + 1 + Type + F32 + Value + 0.15 + + RenderColorGradeWhiteBalanceCCT + + Comment + White balance temperature shift, in Kelvin relative to neutral daylight. Negative warms the image, positive cools it. Range -5000 to +5000. Default 0. Try -2500 tungsten glow, -1000 golden hour, +1500 blue hour, +3000 moonlit. + Persist + 1 + Type + F32 + Value + -1500.0 + + RenderColorGradeWhiteBalanceDuv + + Comment + Green/magenta tint, perpendicular to the temperature axis. Negative pushes magenta, positive pushes green. Range -1 to +1. Default 0. Try -0.4 cyberpunk magenta, +0.4 fluorescent green, +0.75 Matrix green. + Persist + 1 + Type + F32 + Value + -0.05 + + RenderColorGradeWhitePoint + + Comment + Clips values at or above this level to pure white. 1 is identity, 0.95 blooms highlights, 0.9 produces a washed bleach-bypass look. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderDynamicExposureCoefficient + + Comment + Luminance coefficient for dynamic exposure + Persist + 1 + Type + F32 + Value + 0.5 + + RenderDynamicExposureEnabled + + Comment + Enable dynamic exposure adjustment (auto-exposure) when HDR rendering is enabled + Persist + 1 + Type + Boolean + Value + 1 + + RenderDynamicExposureSpeedError + + Comment + Speed at which dynamic exposure adapts while far from the target exposure + Persist + 1 + Type + F32 + Value + 0.1 + + RenderDynamicExposureSpeedTarget + + Comment + Speed at which dynamic exposure settles once near the target exposure + Persist + 1 + Type + F32 + Value + 2.0 + + RenderExposure + + Comment + Exposure value to send to tonemapper. + Persist + 1 + Type + F32 + Value + 1.1 + + RenderFilmGrainAmount + + Comment + [0, 1] Strength of applied film grain effect + Persist + 1 + Type + F32 + Value + 0.03 + + RenderFilmGrainAnimated + + Comment + Whether the film grain effect is animated between frames + Persist + 1 + Type + Boolean + Value + 1 + + RenderFilmGrainRange + + Comment + [0, 1] luma position where grain peaks. 0 = shadows only, 0.5 = midtones (classic film), 1 = highlights only + Persist + 1 + Type + F32 + Value + 0.5 + + RenderFilmGrainSize + + Comment + [1, 8] Size of film grain particles + Persist + 1 + Type + F32 + Value + 1.0 + + RenderFilmGrainStyle + + Comment + Film grain effect style - 0 = mono luma, 1 = color (digital push), 2 = coarse (16mm feel), 3 = photon shot (CCD sensor) + Persist + 1 + Type + S32 + Value + 0 + + RenderFilmGrainTint + + Comment + color of the film grain. vec3(1) = neutral gray. Try vec3(1.0, 0.9, 0.8) for warm film grain, vec3(0.8, 0.9, 1.0) for cool video noise + Persist + 1 + Type + Color3 + Value + + 1.0 + 1.0 + 1.0 + + + RenderGlow + + Comment + Render bloom post effect. + Persist + 1 + Type + Boolean + Value + 1 + + RenderGlowHDR + + Comment + Enable HDR for glow map + Persist + 1 + Type + Boolean + Value + 0 + + RenderGlowIterations + + Comment + Number of times to iterate the glow (higher = wider and smoother but slower) + Persist + 1 + Type + S32 + Value + 2 + + RenderGlowLumWeights + + Comment + Weights for each color channel to be used in calculating luminance (should add up to 1.0) + Persist + 1 + Type + Vector3 + Value + + 1.0 + 0.0 + 0.0 + + + RenderGlowMaxExtractAlpha + + Comment + Max glow alpha value for brightness extraction to auto-glow. + Persist + 1 + Type + F32 + Value + 0.25 + + RenderGlowMinLuminance + + Comment + Min luminance intensity necessary to consider an object bright enough to automatically glow. + Persist + 1 + Type + F32 + Value + 9999 + + RenderGlowNoise + + Comment + Enables glow noise (dithering). Reduces banding from glow in certain cases. + Persist + 1 + Type + Boolean + Value + 1 + + RenderGlowStrength + + Comment + Additive strength of glow. + Persist + 1 + Type + F32 + Value + 0.325 + + RenderGlowWarmthAmount + + Comment + Amount of warmth extraction to use (versus luminance extraction). 0 = lum, 1.0 = warmth + Persist + 1 + Type + F32 + Value + 0.0 + + RenderGlowWarmthWeights + + Comment + Weight of each color channel used before finding the max warmth + Persist + 1 + Type + Vector3 + Value + + 1.0 + 0.5 + 0.7 + + + RenderGlowWidth + + Comment + Glow sample size (higher = wider and softer but eventually more pixelated) + Persist + 1 + Type + F32 + Value + 1.3 + + RenderLensFlareChromaticSpread + + Comment + Red/blue color separation along the streak for a chromatic fringe look. 0 is clean; 1 gives a strong rainbow split. Range 0 to 1. + Persist + 1 + Type + F32 + Value + 0.08 + + RenderLensFlareGhost + + Comment + Intensity of the lens ghost circles. 0 disables ghosts; 1 is neutral; higher boosts them. Range 0 to 5. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderLensFlareGhostCount + + Comment + Number of lens ghost circles placed along the line from the sun through the screen center. Shape control only; use RenderLensFlareGhost to toggle the effect. Range 0 to 8. + Persist + 1 + Type + S32 + Value + 4 + + RenderLensFlareGhostSpacing + + Comment + Distance between consecutive ghosts. Smaller packs them near the sun; larger spreads them across the screen. Range 0.1 to 1. + Persist + 1 + Type + F32 + Value + 0.3 + + RenderLensFlareGlow + + Comment + Intensity of the soft central glow around the sun. 0 disables the glow; 1 is neutral; higher boosts it. Range 0 to 5. + Persist + 1 + Type + F32 + Value + 1.2 + + RenderLensFlareGlowFalloff + + Comment + Softness of the central glow. Lower values give a gentle, spread-out bloom; higher values keep it tight and punchy. Range 1 to 30. + Persist + 1 + Type + F32 + Value + 8.0 + + RenderLensFlareGlowRadius + + Comment + Size of the soft glow bloom directly around the sun. Small values hug the light; larger values create a wider halo of brightness. Range 0.01 to 0.5. + Persist + 1 + Type + F32 + Value + 0.12 + + RenderLensFlareHalo + + Comment + Intensity of the halo ring opposite the sun. 0 disables the halo; 1 is neutral; higher boosts it. Range 0 to 5. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderLensFlareHaloRadius + + Comment + Radius of the halo ring that appears opposite the sun through screen center. Shape control only; use RenderLensFlareHalo to toggle the effect. Range 0.01 to 1. + Persist + 1 + Type + F32 + Value + 0.5 + + RenderLensFlareHaloWidth + + Comment + Thickness of the halo ring. Smaller is a crisp hoop; larger is a soft diffuse ring. Range 0.01 to 0.5. + Persist + 1 + Type + F32 + Value + 0.15 + + RenderLensFlareOcclusionRadius + + Comment + How wide the sun-occlusion test samples the scene depth. Larger values let thin geometry (foliage, wires) dim the flare more gracefully. Range 0.005 to 0.1. + Persist + 1 + Type + F32 + Value + 0.02 + + RenderLensFlareOcclusionTaps + + Comment + Number of depth samples for sun-occlusion testing. Higher values give smoother partial occlusion transitions at a small performance cost. Range 1 to 32. + Persist + 1 + Type + S32 + Value + 9 + + RenderLensFlareStarburst + + Comment + Intensity of the starburst (diffraction-spike) pattern radiating from the sun, like aperture blades in a camera. 0 disables it. Range 0 to 5. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderLensFlareStarburstLength + + Comment + Length of the starburst spikes. 0 is short and tight to the sun; 1 lets them reach far across the screen. Range 0 to 1. + Persist + 1 + Type + F32 + Value + 0.25 + + RenderLensFlareStarburstSharpness + + Comment + How crisp the starburst spikes look. Low values produce soft, wide rays; high values produce thin, needle-like beams. Range 1 to 256. + Persist + 1 + Type + F32 + Value + 24.0 + + 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. + Persist + 1 + Type + S32 + Value + 4 + + RenderLensFlareStreakFalloff + + Comment + How quickly the streak fades toward its tips. Lower values leave a longer visible tail; higher values make the streak fade in close to the sun. Range 0.1 to 10. + Persist + 1 + Type + F32 + Value + 1.5 + + RenderLensFlareStreakIntensity + + Comment + Brightness multiplier for just the streak, separate from the master strength. 1 is neutral; higher boosts the streak without affecting the glow/ghosts. Range 0 to 5. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderLensFlareStreakLength + + Comment + How far the horizontal anamorphic streak stretches across the screen. 0.5 reaches halfway; 1.0 crosses the full width. Range 0.01 to 2. + Persist + 1 + Type + F32 + Value + 0.5 + + RenderLensFlareStreakThickness + + Comment + Vertical thickness of the streak as a fraction of the screen. Lower is a thin hairline, higher is a soft horizontal band. Range 0 to 1. + Persist + 1 + Type + F32 + Value + 0.08 + + RenderLensFlareStreakTint + + Comment + Color of the horizontal streak. The default cool blue mimics coated anamorphic cinema lenses; try warm tones for sci-fi or golden hour looks. + Persist + 1 + Type + Color3 + Value + + 0.6 + 0.7 + 1.0 + + + RenderLensFlareStrength + + Comment + Master intensity of the lens flare effect. 0 turns it off entirely; 1 is full strength. Use this as the on/off switch. + Persist + 1 + Type + F32 + Value + 0.3 + + RenderSplitToneAmount + + Comment + Split toning strength for shadows and highlights. 0 disables the effect; 0.5 is a natural cinematic push. Tints shadows and highlights without shifting overall brightness. + Persist + 1 + Type + F32 + Value + 0.3 + + RenderSplitToneBalance + + Comment + Slides the midpoint between shadow and highlight tints. Negative values widen shadows (good for dark scenes); positive values widen highlights (good for bright scenes). Range -1 to 1. + Persist + 1 + Type + F32 + Value + -0.1 + + RenderSplitToneHighlightTint + + Comment + Target color for highlight tones. Neutral gray (0.5, 0.5, 0.5) is identity. Try warm amber (0.6, 0.5, 0.4) to complement teal shadows. + Persist + 1 + Type + Color3 + Value + + 0.62 + 0.54 + 0.44 + + + RenderSplitToneMidtoneAmount + + Comment + Midtone tint strength, independent from shadow/highlight amount. 0 disables; subtle values (0.15-0.35) add mood without muddying skin. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderSplitToneMidtoneTint + + Comment + Target color for midtones. Neutral gray (0.5, 0.5, 0.5) is identity. Warm tints like (0.55, 0.5, 0.45) flatter skin tones. + Persist + 1 + Type + Color3 + Value + + 0.5 + 0.5 + 0.5 + + + RenderSplitToneShadowTint + + Comment + Target color for shadow tones. Neutral gray (0.5, 0.5, 0.5) is identity. Try cool teal (0.35, 0.5, 0.55) for classic teal-and-orange looks. + Persist + 1 + Type + Color3 + Value + + 0.44 + 0.48 + 0.55 + + + RenderTonemapACESWhite + + Comment + Point at which HDR colors cross over into being pure white + Persist + 1 + Type + F32 + Value + 6.0 + + RenderTonemapAgxContrast + + Comment + Contrast of tonemapped colors + Persist + 1 + Type + F32 + Value + 1.3 + + RenderTonemapAgxWhite + + Comment + Point at which HDR colors cross over into being pure white + Persist + 1 + Type + F32 + Value + 16.29 + + RenderTonemapFilmicWhite + + Comment + Point at which HDR colors cross over into being pure white + Persist + 1 + Type + F32 + Value + 6.0 + + RenderTonemapMix + + Comment + Mix between linear and tonemapped colors (0.0(Linear) - 1.0(Tonemapped) + Persist + 1 + Type + F32 + Value + 0.7 + + RenderTonemapReinhardWhite + + Comment + Point at which HDR colors cross over into being pure white + Persist + 1 + Type + F32 + Value + 6.0 + + RenderUseExposureSkySettings + + Comment + Use exposure sky settings instead of deriving from HDR scale. + Persist + 1 + Type + Boolean + Value + 0 + + RenderVignetteAmount + + Comment + [0, 1] Opacity of applied vignette effect + Persist + 1 + Type + F32 + Value + 0.18 + + RenderVignetteCenter + + Comment + [-0.5, 0.5] Offset of vignette center x and y, z component unused + Persist + 1 + Type + Vector3 + Value + + 0.0 + 0.0 + 0.0 + + + RenderVignetteColor + + Comment + Vignette edge color + Persist + 1 + Type + Color3 + Value + + 0.0 + 0.0 + 0.0 + + + RenderVignetteCorrectAspect + + Comment + Whether the vignette effect is corrected for aspect ratio + Persist + 1 + Type + Boolean + Value + 0 + + RenderVignetteFeather + + Comment + [0.2, 4.0] controls the shape of the darkening curve from edge to center. <1 = spreads darkening inward (gentle haze across frame) 1 = linear smoothstep falloff (current behavior) >1 = concentrates darkening at edges (sharp corner darkening only) + Persist + 1 + Type + F32 + Value + 0.5 + + RenderVignetteMidColor + + Comment + Intermediate ramp color of vignette + Persist + 1 + Type + Color3 + Value + + 0.0 + 0.0 + 0.0 + + + RenderVignetteMidPoint + + Comment + [0, 1] Mid point of vignette color shift for three-color ramp - 0 disables (two-color fallback) + Persist + 1 + Type + F32 + Value + 0.0 + + RenderVignetteRadius + + Comment + [0.25, 1.5] Measured in image-height units. 1.0 reaches top/bottom edges on any aspect. + Persist + 1 + Type + F32 + Value + 1.15 + + RenderVignetteShape + + Comment + [0, 1] 0 circular, 1 rounded square. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderVignetteSoft + + Comment + [0, 1] Softness of the vignette edge + Persist + 1 + Type + F32 + Value + 0.5 + + + diff --git a/indra/newview/app_settings/looks/Neutral.xml b/indra/newview/app_settings/looks/Neutral.xml new file mode 100644 index 0000000000..220f7f6284 --- /dev/null +++ b/indra/newview/app_settings/looks/Neutral.xml @@ -0,0 +1,1228 @@ + + + + AlchemyRenderTonemapType + + Comment + What tonemapper to use: 0 = Khronos Neutral, 1 = ACES Hill, 2 = ACES Boosted, 3 = Reinhard, 4 = Filmic, 5 = GT, 6 = AgX + Persist + 1 + Type + S32 + Value + 1 + + RenderBloomAlphaGlowBoost + + Comment + Multiplier applied to alpha-tagged glow (legacy prim glow parameter) when fed into HDR bloom. Compensates for pre-tonemap composite so prim-glow halos match legacy brightness. + Persist + 1 + Type + F32 + Value + 2.5 + + RenderBloomHalation + + Comment + Enable the halation signal in HDR bloom. When off, the bloom pyramid allocates as R11F_G11F_B10F (half the bandwidth of RGBA16F) and skips the warmth extract and tinted composite. Requires RenderBloomHDR. + Persist + 1 + Type + Boolean + Value + 0 + + RenderBloomHalationStrength + + Comment + Photographic halation (warm halo around bright highlights). 0 disables. 0.25 is a gentle cinematic feel. Requires RenderBloomHalation. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderBloomHalationTint + + Comment + Tint color for the halation halo (linear RGB). Default is a warm amber. Requires RenderBloomHalation. + Persist + 1 + Type + Color3 + Value + + 1.0 + 0.35 + 0.15 + + + RenderBloomKnee + + Comment + Soft-knee width below the bloom threshold. Wider values give a softer, more gradual bloom onset. Range 0 to 1. + Persist + 1 + Type + F32 + Value + 0.5 + + 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. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderBloomStrength + + Comment + HDR bloom composite strength. Multiplies the final bloom pyramid before it is additively blended into the scene. Independent of RenderGlowStrength so HDR bloom and legacy glow can be tuned separately. + Persist + 1 + Type + F32 + Value + 0.325 + + RenderBloomThreshold + + Comment + HDR bloom luminance threshold in linear light. Values above this bloom; below, fall off softly via the knee. 1.0 = roughly "brighter than white". + Persist + 1 + Type + F32 + Value + 2.5 + + RenderCASSharpness + + Comment + Level of sharpening to apply via Contrast Adaptive Sharpening (0.0(off) - 1.0) + Persist + 1 + Type + F32 + Value + 0.4 + + RenderChromaticAberrationAngle + + Comment + Rotates the red/blue split direction, in degrees. 0 is horizontal; 90 is vertical. Only meaningful when Anisotropy is nonzero. Range 0 to 360. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderChromaticAberrationAnisotropy + + Comment + Stretches the fringe along the angle axis. 0 gives an even radial fringe; positive values elongate it into a directional smear for anamorphic-style aberration. Range -1 to 1. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderChromaticAberrationFalloff + + Comment + How quickly the aberration grows from the screen center outward. 1 is a linear ramp; higher values keep the center clean and push fringing to the edges. Range 0.5 to 4. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderChromaticAberrationOffsetBX + + Comment + Blue channel horizontal offset direction. Typically opposite sign of OffsetRX for a standard red/blue split. Range -1 to 1. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderChromaticAberrationOffsetBY + + Comment + Blue channel vertical offset direction. Pairs with OffsetBX. 0 keeps blue split purely horizontal. Range -1 to 1. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderChromaticAberrationOffsetRX + + Comment + Red channel horizontal offset direction. -1 shifts red inward on the X axis, +1 outward. Combined with RY for per-channel fringe direction. Range -1 to 1. + Persist + 1 + Type + F32 + Value + -1.0 + + RenderChromaticAberrationOffsetRY + + Comment + Red channel vertical offset direction. Pairs with OffsetRX. 0 keeps red split purely horizontal. Range -1 to 1. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderChromaticAberrationStrength + + Comment + Overall intensity of the chromatic aberration fringe. 0 disables the effect. 0.15 gives a subtle cinema lens feel; 0.5 is heavy. Range 0 to 1. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderColorGrade + + Comment + Enable color grading + Persist + 1 + Type + Boolean + Value + 0 + + RenderColorGradeBlackPoint + + Comment + Crushes values at or below this level to pure black. 0 is identity, 0.05 deepens blacks cinematically, 0.1+ starts clipping shadow detail. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderColorGradeBrightness + + Comment + Shifts the whole image lighter or darker. Range -0.5 to 0.5; small values (±0.05) are already noticeable. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderColorGradeContrast + + Comment + S-curve around midgray. 1 is identity; less than 1 flattens (matte film look), greater than 1 punches (values above 1.3 start clipping). + Persist + 1 + Type + F32 + Value + 1.0 + + RenderColorGradeCurveShoulder + + Comment + Per-channel filmic curve shoulder point (RGB). Pixels at or above this input level flatten toward white. Lower values (around 0.85) give a tighter roll-off. + Persist + 1 + Type + Color3 + Value + + 1.0 + 1.0 + 1.0 + + + RenderColorGradeCurveStrength + + Comment + Per-channel curve blend strength (RGB). 0 disables; 0.4 is typical film, differing values per channel create cross-process looks. + Persist + 1 + Type + Color3 + Value + + 0.0 + 0.0 + 0.0 + + + RenderColorGradeCurveToe + + Comment + Per-channel filmic curve toe point (RGB). Pixels at or below this input level flatten toward black. Try (0.05, 0.05, 0.1) to lift shadow blues. + Persist + 1 + Type + Color3 + Value + + 0.0 + 0.0 + 0.0 + + + RenderColorGradeGain + + Comment + Highlight gain (R,G,B): multiplies the brightest tones to boost, clip, or tint whites. Per-channel range 0.5 to 1.5. Default (1,1,1). Try (1.05,1.02,0.95) warm highlights, (0.95,1.0,1.05) cool highlights, uniform <1 to tame blowouts. + Persist + 1 + Type + Vector3 + Value + + 1.0 + 1.0 + 1.0 + + + RenderColorGradeGamma + + Comment + Midtone gamma (R,G,B): bends the middle of the tonal range without moving blacks or whites. Above 1 brightens mids, below 1 darkens them. Per-channel range 0.5 to 1.5. Default (1,1,1). Try (1.1,1.1,1.1) open mids, (1.1,1.0,0.95) warm mids. + Persist + 1 + Type + Vector3 + Value + + 1.0 + 1.0 + 1.0 + + + RenderColorGradeHighlights + + Comment + Recovers (negative) or boosts (positive) highlights without touching midtones. Use -0.3 to pull blown skies back from clipping. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderColorGradeHueShift + + Comment + Rotates all hues around the color wheel in degrees. Range -180 to 180. 30 warms everything; 180 is a full complement (inverted). + Persist + 1 + Type + F32 + Value + 0.0 + + RenderColorGradeLUT + + Comment + Name of image file for color grading LUT(TGA, PNG, or WebP) + Persist + 1 + Type + String + Value + + + RenderColorGradeLUTStrength + + Comment + Strength of the color grading LUT effect + Persist + 1 + Type + F32 + Value + 1.0 + + RenderColorGradeLift + + Comment + Shadow lift (R,G,B): adds to the darkest tones to raise the black floor and tint shadows. Per-channel range -0.5 to +0.5. Default (0,0,0). Try (0.02,0.02,0.05) cool shadow mist, (0.05,0.03,0) warm lift, negatives crush toward black. + Persist + 1 + Type + Vector3 + Value + + 0.0 + 0.0 + 0.0 + + + RenderColorGradeSaturation + + Comment + Uniform saturation. 1 is identity, 0 is black-and-white, 1.2 is punchy, 1.5 is heavily stylized. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderColorGradeShadows + + Comment + Lifts (positive) or crushes (negative) shadows without touching midtones. Use +0.2 to reveal shadow detail in dark scenes. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderColorGradeVibrance + + Comment + Smart saturation that pushes muted colors harder than already-saturated ones. Skin-friendly; range -1 to 1, 0.3 is a subtle lift. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderColorGradeWhiteBalanceCCT + + Comment + White balance temperature shift, in Kelvin relative to neutral daylight. Negative warms the image, positive cools it. Range -5000 to +5000. Default 0. Try -2500 tungsten glow, -1000 golden hour, +1500 blue hour, +3000 moonlit. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderColorGradeWhiteBalanceDuv + + Comment + Green/magenta tint, perpendicular to the temperature axis. Negative pushes magenta, positive pushes green. Range -1 to +1. Default 0. Try -0.4 cyberpunk magenta, +0.4 fluorescent green, +0.75 Matrix green. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderColorGradeWhitePoint + + Comment + Clips values at or above this level to pure white. 1 is identity, 0.95 blooms highlights, 0.9 produces a washed bleach-bypass look. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderDynamicExposureCoefficient + + Comment + Luminance coefficient for dynamic exposure + Persist + 1 + Type + F32 + Value + 0.5 + + RenderDynamicExposureEnabled + + Comment + Enable dynamic exposure adjustment (auto-exposure) when HDR rendering is enabled + Persist + 1 + Type + Boolean + Value + 1 + + RenderDynamicExposureSpeedError + + Comment + Speed at which dynamic exposure adapts while far from the target exposure + Persist + 1 + Type + F32 + Value + 0.1 + + RenderDynamicExposureSpeedTarget + + Comment + Speed at which dynamic exposure settles once near the target exposure + Persist + 1 + Type + F32 + Value + 2.0 + + RenderExposure + + Comment + Exposure value to send to tonemapper. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderFilmGrainAmount + + Comment + [0, 1] Strength of applied film grain effect + Persist + 1 + Type + F32 + Value + 0.0 + + RenderFilmGrainAnimated + + Comment + Whether the film grain effect is animated between frames + Persist + 1 + Type + Boolean + Value + 1 + + RenderFilmGrainRange + + Comment + [0, 1] luma position where grain peaks. 0 = shadows only, 0.5 = midtones (classic film), 1 = highlights only + Persist + 1 + Type + F32 + Value + 0.5 + + RenderFilmGrainSize + + Comment + [1, 8] Size of film grain particles + Persist + 1 + Type + F32 + Value + 1.0 + + RenderFilmGrainStyle + + Comment + Film grain effect style - 0 = mono luma, 1 = color (digital push), 2 = coarse (16mm feel), 3 = photon shot (CCD sensor) + Persist + 1 + Type + S32 + Value + 0 + + RenderFilmGrainTint + + Comment + color of the film grain. vec3(1) = neutral gray. Try vec3(1.0, 0.9, 0.8) for warm film grain, vec3(0.8, 0.9, 1.0) for cool video noise + Persist + 1 + Type + Color3 + Value + + 1.0 + 1.0 + 1.0 + + + RenderGlow + + Comment + Render bloom post effect. + Persist + 1 + Type + Boolean + Value + 1 + + RenderGlowHDR + + Comment + Enable HDR for glow map + Persist + 1 + Type + Boolean + Value + 0 + + RenderGlowIterations + + Comment + Number of times to iterate the glow (higher = wider and smoother but slower) + Persist + 1 + Type + S32 + Value + 2 + + RenderGlowLumWeights + + Comment + Weights for each color channel to be used in calculating luminance (should add up to 1.0) + Persist + 1 + Type + Vector3 + Value + + 1.0 + 0.0 + 0.0 + + + RenderGlowMaxExtractAlpha + + Comment + Max glow alpha value for brightness extraction to auto-glow. + Persist + 1 + Type + F32 + Value + 0.25 + + RenderGlowMinLuminance + + Comment + Min luminance intensity necessary to consider an object bright enough to automatically glow. + Persist + 1 + Type + F32 + Value + 9999 + + RenderGlowNoise + + Comment + Enables glow noise (dithering). Reduces banding from glow in certain cases. + Persist + 1 + Type + Boolean + Value + 1 + + RenderGlowStrength + + Comment + Additive strength of glow. + Persist + 1 + Type + F32 + Value + 0.325 + + RenderGlowWarmthAmount + + Comment + Amount of warmth extraction to use (versus luminance extraction). 0 = lum, 1.0 = warmth + Persist + 1 + Type + F32 + Value + 0.0 + + RenderGlowWarmthWeights + + Comment + Weight of each color channel used before finding the max warmth + Persist + 1 + Type + Vector3 + Value + + 1.0 + 0.5 + 0.7 + + + RenderGlowWidth + + Comment + Glow sample size (higher = wider and softer but eventually more pixelated) + Persist + 1 + Type + F32 + Value + 1.3 + + RenderLensFlareChromaticSpread + + Comment + Red/blue color separation along the streak for a chromatic fringe look. 0 is clean; 1 gives a strong rainbow split. Range 0 to 1. + Persist + 1 + Type + F32 + Value + 0.08 + + RenderLensFlareGhost + + Comment + Intensity of the lens ghost circles. 0 disables ghosts; 1 is neutral; higher boosts them. Range 0 to 5. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderLensFlareGhostCount + + Comment + Number of lens ghost circles placed along the line from the sun through the screen center. Shape control only; use RenderLensFlareGhost to toggle the effect. Range 0 to 8. + Persist + 1 + Type + S32 + Value + 4 + + RenderLensFlareGhostSpacing + + Comment + Distance between consecutive ghosts. Smaller packs them near the sun; larger spreads them across the screen. Range 0.1 to 1. + Persist + 1 + Type + F32 + Value + 0.3 + + RenderLensFlareGlow + + Comment + Intensity of the soft central glow around the sun. 0 disables the glow; 1 is neutral; higher boosts it. Range 0 to 5. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderLensFlareGlowFalloff + + Comment + Softness of the central glow. Lower values give a gentle, spread-out bloom; higher values keep it tight and punchy. Range 1 to 30. + Persist + 1 + Type + F32 + Value + 8.0 + + RenderLensFlareGlowRadius + + Comment + Size of the soft glow bloom directly around the sun. Small values hug the light; larger values create a wider halo of brightness. Range 0.01 to 0.5. + Persist + 1 + Type + F32 + Value + 0.12 + + RenderLensFlareHalo + + Comment + Intensity of the halo ring opposite the sun. 0 disables the halo; 1 is neutral; higher boosts it. Range 0 to 5. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderLensFlareHaloRadius + + Comment + Radius of the halo ring that appears opposite the sun through screen center. Shape control only; use RenderLensFlareHalo to toggle the effect. Range 0.01 to 1. + Persist + 1 + Type + F32 + Value + 0.5 + + RenderLensFlareHaloWidth + + Comment + Thickness of the halo ring. Smaller is a crisp hoop; larger is a soft diffuse ring. Range 0.01 to 0.5. + Persist + 1 + Type + F32 + Value + 0.15 + + RenderLensFlareOcclusionRadius + + Comment + How wide the sun-occlusion test samples the scene depth. Larger values let thin geometry (foliage, wires) dim the flare more gracefully. Range 0.005 to 0.1. + Persist + 1 + Type + F32 + Value + 0.02 + + RenderLensFlareOcclusionTaps + + Comment + Number of depth samples for sun-occlusion testing. Higher values give smoother partial occlusion transitions at a small performance cost. Range 1 to 32. + Persist + 1 + Type + S32 + Value + 9 + + RenderLensFlareStarburst + + Comment + Intensity of the starburst (diffraction-spike) pattern radiating from the sun, like aperture blades in a camera. 0 disables it. Range 0 to 5. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderLensFlareStarburstLength + + Comment + Length of the starburst spikes. 0 is short and tight to the sun; 1 lets them reach far across the screen. Range 0 to 1. + Persist + 1 + Type + F32 + Value + 0.25 + + RenderLensFlareStarburstSharpness + + Comment + How crisp the starburst spikes look. Low values produce soft, wide rays; high values produce thin, needle-like beams. Range 1 to 256. + Persist + 1 + Type + F32 + Value + 24.0 + + 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. + Persist + 1 + Type + S32 + Value + 4 + + RenderLensFlareStreakFalloff + + Comment + How quickly the streak fades toward its tips. Lower values leave a longer visible tail; higher values make the streak fade in close to the sun. Range 0.1 to 10. + Persist + 1 + Type + F32 + Value + 1.5 + + RenderLensFlareStreakIntensity + + Comment + Brightness multiplier for just the streak, separate from the master strength. 1 is neutral; higher boosts the streak without affecting the glow/ghosts. Range 0 to 5. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderLensFlareStreakLength + + Comment + How far the horizontal anamorphic streak stretches across the screen. 0.5 reaches halfway; 1.0 crosses the full width. Range 0.01 to 2. + Persist + 1 + Type + F32 + Value + 0.5 + + RenderLensFlareStreakThickness + + Comment + Vertical thickness of the streak as a fraction of the screen. Lower is a thin hairline, higher is a soft horizontal band. Range 0 to 1. + Persist + 1 + Type + F32 + Value + 0.08 + + RenderLensFlareStreakTint + + Comment + Color of the horizontal streak. The default cool blue mimics coated anamorphic cinema lenses; try warm tones for sci-fi or golden hour looks. + Persist + 1 + Type + Color3 + Value + + 0.6 + 0.7 + 1.0 + + + RenderLensFlareStrength + + Comment + Master intensity of the lens flare effect. 0 turns it off entirely; 1 is full strength. Use this as the on/off switch. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderSplitToneAmount + + Comment + Split toning strength for shadows and highlights. 0 disables the effect; 0.5 is a natural cinematic push. Tints shadows and highlights without shifting overall brightness. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderSplitToneBalance + + Comment + Slides the midpoint between shadow and highlight tints. Negative values widen shadows (good for dark scenes); positive values widen highlights (good for bright scenes). Range -1 to 1. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderSplitToneHighlightTint + + Comment + Target color for highlight tones. Neutral gray (0.5, 0.5, 0.5) is identity. Try warm amber (0.6, 0.5, 0.4) to complement teal shadows. + Persist + 1 + Type + Color3 + Value + + 0.5 + 0.5 + 0.5 + + + RenderSplitToneMidtoneAmount + + Comment + Midtone tint strength, independent from shadow/highlight amount. 0 disables; subtle values (0.15-0.35) add mood without muddying skin. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderSplitToneMidtoneTint + + Comment + Target color for midtones. Neutral gray (0.5, 0.5, 0.5) is identity. Warm tints like (0.55, 0.5, 0.45) flatter skin tones. + Persist + 1 + Type + Color3 + Value + + 0.5 + 0.5 + 0.5 + + + RenderSplitToneShadowTint + + Comment + Target color for shadow tones. Neutral gray (0.5, 0.5, 0.5) is identity. Try cool teal (0.35, 0.5, 0.55) for classic teal-and-orange looks. + Persist + 1 + Type + Color3 + Value + + 0.5 + 0.5 + 0.5 + + + RenderTonemapACESWhite + + Comment + Point at which HDR colors cross over into being pure white + Persist + 1 + Type + F32 + Value + 6.0 + + RenderTonemapAgxContrast + + Comment + Contrast of tonemapped colors + Persist + 1 + Type + F32 + Value + 1.3 + + RenderTonemapAgxWhite + + Comment + Point at which HDR colors cross over into being pure white + Persist + 1 + Type + F32 + Value + 16.29 + + RenderTonemapFilmicWhite + + Comment + Point at which HDR colors cross over into being pure white + Persist + 1 + Type + F32 + Value + 6.0 + + RenderTonemapMix + + Comment + Mix between linear and tonemapped colors (0.0(Linear) - 1.0(Tonemapped) + Persist + 1 + Type + F32 + Value + 0.7 + + RenderTonemapReinhardWhite + + Comment + Point at which HDR colors cross over into being pure white + Persist + 1 + Type + F32 + Value + 6.0 + + RenderUseExposureSkySettings + + Comment + Use exposure sky settings instead of deriving from HDR scale. + Persist + 1 + Type + Boolean + Value + 0 + + RenderVignetteAmount + + Comment + [0, 1] Opacity of applied vignette effect + Persist + 1 + Type + F32 + Value + 0.0 + + RenderVignetteCenter + + Comment + [-0.5, 0.5] Offset of vignette center x and y, z component unused + Persist + 1 + Type + Vector3 + Value + + 0.0 + 0.0 + 0.0 + + + RenderVignetteColor + + Comment + Vignette edge color + Persist + 1 + Type + Color3 + Value + + 0.0 + 0.0 + 0.0 + + + RenderVignetteCorrectAspect + + Comment + Whether the vignette effect is corrected for aspect ratio + Persist + 1 + Type + Boolean + Value + 0 + + RenderVignetteFeather + + Comment + [0.2, 4.0] controls the shape of the darkening curve from edge to center. <1 = spreads darkening inward (gentle haze across frame) 1 = linear smoothstep falloff (current behavior) >1 = concentrates darkening at edges (sharp corner darkening only) + Persist + 1 + Type + F32 + Value + 0.5 + + RenderVignetteMidColor + + Comment + Intermediate ramp color of vignette + Persist + 1 + Type + Color3 + Value + + 0.0 + 0.0 + 0.0 + + + RenderVignetteMidPoint + + Comment + [0, 1] Mid point of vignette color shift for three-color ramp - 0 disables (two-color fallback) + Persist + 1 + Type + F32 + Value + 0.0 + + RenderVignetteRadius + + Comment + [0.25, 1.5] Measured in image-height units. 1.0 reaches top/bottom edges on any aspect. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderVignetteShape + + Comment + [0, 1] 0 circular, 1 rounded square. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderVignetteSoft + + Comment + [0, 1] Softness of the vignette edge + Persist + 1 + Type + F32 + Value + 0.5 + + + diff --git a/indra/newview/app_settings/looks/Soft%20Film.xml b/indra/newview/app_settings/looks/Soft%20Film.xml new file mode 100644 index 0000000000..58df533f4f --- /dev/null +++ b/indra/newview/app_settings/looks/Soft%20Film.xml @@ -0,0 +1,1228 @@ + + + + AlchemyRenderTonemapType + + Comment + What tonemapper to use: 0 = Khronos Neutral, 1 = ACES Hill, 2 = ACES Boosted, 3 = Reinhard, 4 = Filmic, 5 = GT, 6 = AgX + Persist + 1 + Type + S32 + Value + 4 + + RenderBloomAlphaGlowBoost + + Comment + Multiplier applied to alpha-tagged glow (legacy prim glow parameter) when fed into HDR bloom. Compensates for pre-tonemap composite so prim-glow halos match legacy brightness. + Persist + 1 + Type + F32 + Value + 2.5 + + RenderBloomHalation + + Comment + Enable the halation signal in HDR bloom. When off, the bloom pyramid allocates as R11F_G11F_B10F (half the bandwidth of RGBA16F) and skips the warmth extract and tinted composite. Requires RenderBloomHDR. + Persist + 1 + Type + Boolean + Value + 0 + + RenderBloomHalationStrength + + Comment + Photographic halation (warm halo around bright highlights). 0 disables. 0.25 is a gentle cinematic feel. Requires RenderBloomHalation. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderBloomHalationTint + + Comment + Tint color for the halation halo (linear RGB). Default is a warm amber. Requires RenderBloomHalation. + Persist + 1 + Type + Color3 + Value + + 1.0 + 0.35 + 0.15 + + + RenderBloomKnee + + Comment + Soft-knee width below the bloom threshold. Wider values give a softer, more gradual bloom onset. Range 0 to 1. + Persist + 1 + Type + F32 + Value + 0.5 + + 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. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderBloomStrength + + Comment + HDR bloom composite strength. Multiplies the final bloom pyramid before it is additively blended into the scene. Independent of RenderGlowStrength so HDR bloom and legacy glow can be tuned separately. + Persist + 1 + Type + F32 + Value + 0.325 + + RenderBloomThreshold + + Comment + HDR bloom luminance threshold in linear light. Values above this bloom; below, fall off softly via the knee. 1.0 = roughly "brighter than white". + Persist + 1 + Type + F32 + Value + 2.5 + + RenderCASSharpness + + Comment + Level of sharpening to apply via Contrast Adaptive Sharpening (0.0(off) - 1.0) + Persist + 1 + Type + F32 + Value + 0.2 + + RenderChromaticAberrationAngle + + Comment + Rotates the red/blue split direction, in degrees. 0 is horizontal; 90 is vertical. Only meaningful when Anisotropy is nonzero. Range 0 to 360. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderChromaticAberrationAnisotropy + + Comment + Stretches the fringe along the angle axis. 0 gives an even radial fringe; positive values elongate it into a directional smear for anamorphic-style aberration. Range -1 to 1. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderChromaticAberrationFalloff + + Comment + How quickly the aberration grows from the screen center outward. 1 is a linear ramp; higher values keep the center clean and push fringing to the edges. Range 0.5 to 4. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderChromaticAberrationOffsetBX + + Comment + Blue channel horizontal offset direction. Typically opposite sign of OffsetRX for a standard red/blue split. Range -1 to 1. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderChromaticAberrationOffsetBY + + Comment + Blue channel vertical offset direction. Pairs with OffsetBX. 0 keeps blue split purely horizontal. Range -1 to 1. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderChromaticAberrationOffsetRX + + Comment + Red channel horizontal offset direction. -1 shifts red inward on the X axis, +1 outward. Combined with RY for per-channel fringe direction. Range -1 to 1. + Persist + 1 + Type + F32 + Value + -1.0 + + RenderChromaticAberrationOffsetRY + + Comment + Red channel vertical offset direction. Pairs with OffsetRX. 0 keeps red split purely horizontal. Range -1 to 1. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderChromaticAberrationStrength + + Comment + Overall intensity of the chromatic aberration fringe. 0 disables the effect. 0.15 gives a subtle cinema lens feel; 0.5 is heavy. Range 0 to 1. + Persist + 1 + Type + F32 + Value + 0.06 + + RenderColorGrade + + Comment + Enable color grading + Persist + 1 + Type + Boolean + Value + 1 + + RenderColorGradeBlackPoint + + Comment + Crushes values at or below this level to pure black. 0 is identity, 0.05 deepens blacks cinematically, 0.1+ starts clipping shadow detail. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderColorGradeBrightness + + Comment + Shifts the whole image lighter or darker. Range -0.5 to 0.5; small values (±0.05) are already noticeable. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderColorGradeContrast + + Comment + S-curve around midgray. 1 is identity; less than 1 flattens (matte film look), greater than 1 punches (values above 1.3 start clipping). + Persist + 1 + Type + F32 + Value + 1.06 + + RenderColorGradeCurveShoulder + + Comment + Per-channel filmic curve shoulder point (RGB). Pixels at or above this input level flatten toward white. Lower values (around 0.85) give a tighter roll-off. + Persist + 1 + Type + Color3 + Value + + 1.0 + 1.0 + 1.0 + + + RenderColorGradeCurveStrength + + Comment + Per-channel curve blend strength (RGB). 0 disables; 0.4 is typical film, differing values per channel create cross-process looks. + Persist + 1 + Type + Color3 + Value + + 0.0 + 0.0 + 0.0 + + + RenderColorGradeCurveToe + + Comment + Per-channel filmic curve toe point (RGB). Pixels at or below this input level flatten toward black. Try (0.05, 0.05, 0.1) to lift shadow blues. + Persist + 1 + Type + Color3 + Value + + 0.0 + 0.0 + 0.0 + + + RenderColorGradeGain + + Comment + Highlight gain (R,G,B): multiplies the brightest tones to boost, clip, or tint whites. Per-channel range 0.5 to 1.5. Default (1,1,1). Try (1.05,1.02,0.95) warm highlights, (0.95,1.0,1.05) cool highlights, uniform <1 to tame blowouts. + Persist + 1 + Type + Vector3 + Value + + 1.0 + 1.0 + 1.0 + + + RenderColorGradeGamma + + Comment + Midtone gamma (R,G,B): bends the middle of the tonal range without moving blacks or whites. Above 1 brightens mids, below 1 darkens them. Per-channel range 0.5 to 1.5. Default (1,1,1). Try (1.1,1.1,1.1) open mids, (1.1,1.0,0.95) warm mids. + Persist + 1 + Type + Vector3 + Value + + 1.0 + 1.0 + 1.0 + + + RenderColorGradeHighlights + + Comment + Recovers (negative) or boosts (positive) highlights without touching midtones. Use -0.3 to pull blown skies back from clipping. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderColorGradeHueShift + + Comment + Rotates all hues around the color wheel in degrees. Range -180 to 180. 30 warms everything; 180 is a full complement (inverted). + Persist + 1 + Type + F32 + Value + 0.0 + + RenderColorGradeLUT + + Comment + Name of image file for color grading LUT(TGA, PNG, or WebP) + Persist + 1 + Type + String + Value + + + RenderColorGradeLUTStrength + + Comment + Strength of the color grading LUT effect + Persist + 1 + Type + F32 + Value + 1.0 + + RenderColorGradeLift + + Comment + Shadow lift (R,G,B): adds to the darkest tones to raise the black floor and tint shadows. Per-channel range -0.5 to +0.5. Default (0,0,0). Try (0.02,0.02,0.05) cool shadow mist, (0.05,0.03,0) warm lift, negatives crush toward black. + Persist + 1 + Type + Vector3 + Value + + 0.0 + 0.0 + 0.0 + + + RenderColorGradeSaturation + + Comment + Uniform saturation. 1 is identity, 0 is black-and-white, 1.2 is punchy, 1.5 is heavily stylized. + Persist + 1 + Type + F32 + Value + 0.96 + + RenderColorGradeShadows + + Comment + Lifts (positive) or crushes (negative) shadows without touching midtones. Use +0.2 to reveal shadow detail in dark scenes. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderColorGradeVibrance + + Comment + Smart saturation that pushes muted colors harder than already-saturated ones. Skin-friendly; range -1 to 1, 0.3 is a subtle lift. + Persist + 1 + Type + F32 + Value + 0.12 + + RenderColorGradeWhiteBalanceCCT + + Comment + White balance temperature shift, in Kelvin relative to neutral daylight. Negative warms the image, positive cools it. Range -5000 to +5000. Default 0. Try -2500 tungsten glow, -1000 golden hour, +1500 blue hour, +3000 moonlit. + Persist + 1 + Type + F32 + Value + -300.0 + + RenderColorGradeWhiteBalanceDuv + + Comment + Green/magenta tint, perpendicular to the temperature axis. Negative pushes magenta, positive pushes green. Range -1 to +1. Default 0. Try -0.4 cyberpunk magenta, +0.4 fluorescent green, +0.75 Matrix green. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderColorGradeWhitePoint + + Comment + Clips values at or above this level to pure white. 1 is identity, 0.95 blooms highlights, 0.9 produces a washed bleach-bypass look. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderDynamicExposureCoefficient + + Comment + Luminance coefficient for dynamic exposure + Persist + 1 + Type + F32 + Value + 0.5 + + RenderDynamicExposureEnabled + + Comment + Enable dynamic exposure adjustment (auto-exposure) when HDR rendering is enabled + Persist + 1 + Type + Boolean + Value + 1 + + RenderDynamicExposureSpeedError + + Comment + Speed at which dynamic exposure adapts while far from the target exposure + Persist + 1 + Type + F32 + Value + 0.1 + + RenderDynamicExposureSpeedTarget + + Comment + Speed at which dynamic exposure settles once near the target exposure + Persist + 1 + Type + F32 + Value + 2.0 + + RenderExposure + + Comment + Exposure value to send to tonemapper. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderFilmGrainAmount + + Comment + [0, 1] Strength of applied film grain effect + Persist + 1 + Type + F32 + Value + 0.05 + + RenderFilmGrainAnimated + + Comment + Whether the film grain effect is animated between frames + Persist + 1 + Type + Boolean + Value + 1 + + RenderFilmGrainRange + + Comment + [0, 1] luma position where grain peaks. 0 = shadows only, 0.5 = midtones (classic film), 1 = highlights only + Persist + 1 + Type + F32 + Value + 0.5 + + RenderFilmGrainSize + + Comment + [1, 8] Size of film grain particles + Persist + 1 + Type + F32 + Value + 1.4 + + RenderFilmGrainStyle + + Comment + Film grain effect style - 0 = mono luma, 1 = color (digital push), 2 = coarse (16mm feel), 3 = photon shot (CCD sensor) + Persist + 1 + Type + S32 + Value + 2 + + RenderFilmGrainTint + + Comment + color of the film grain. vec3(1) = neutral gray. Try vec3(1.0, 0.9, 0.8) for warm film grain, vec3(0.8, 0.9, 1.0) for cool video noise + Persist + 1 + Type + Color3 + Value + + 1.0 + 1.0 + 1.0 + + + RenderGlow + + Comment + Render bloom post effect. + Persist + 1 + Type + Boolean + Value + 1 + + RenderGlowHDR + + Comment + Enable HDR for glow map + Persist + 1 + Type + Boolean + Value + 0 + + RenderGlowIterations + + Comment + Number of times to iterate the glow (higher = wider and smoother but slower) + Persist + 1 + Type + S32 + Value + 2 + + RenderGlowLumWeights + + Comment + Weights for each color channel to be used in calculating luminance (should add up to 1.0) + Persist + 1 + Type + Vector3 + Value + + 1.0 + 0.0 + 0.0 + + + RenderGlowMaxExtractAlpha + + Comment + Max glow alpha value for brightness extraction to auto-glow. + Persist + 1 + Type + F32 + Value + 0.25 + + RenderGlowMinLuminance + + Comment + Min luminance intensity necessary to consider an object bright enough to automatically glow. + Persist + 1 + Type + F32 + Value + 9999 + + RenderGlowNoise + + Comment + Enables glow noise (dithering). Reduces banding from glow in certain cases. + Persist + 1 + Type + Boolean + Value + 1 + + RenderGlowStrength + + Comment + Additive strength of glow. + Persist + 1 + Type + F32 + Value + 0.325 + + RenderGlowWarmthAmount + + Comment + Amount of warmth extraction to use (versus luminance extraction). 0 = lum, 1.0 = warmth + Persist + 1 + Type + F32 + Value + 0.0 + + RenderGlowWarmthWeights + + Comment + Weight of each color channel used before finding the max warmth + Persist + 1 + Type + Vector3 + Value + + 1.0 + 0.5 + 0.7 + + + RenderGlowWidth + + Comment + Glow sample size (higher = wider and softer but eventually more pixelated) + Persist + 1 + Type + F32 + Value + 1.3 + + RenderLensFlareChromaticSpread + + Comment + Red/blue color separation along the streak for a chromatic fringe look. 0 is clean; 1 gives a strong rainbow split. Range 0 to 1. + Persist + 1 + Type + F32 + Value + 0.08 + + RenderLensFlareGhost + + Comment + Intensity of the lens ghost circles. 0 disables ghosts; 1 is neutral; higher boosts them. Range 0 to 5. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderLensFlareGhostCount + + Comment + Number of lens ghost circles placed along the line from the sun through the screen center. Shape control only; use RenderLensFlareGhost to toggle the effect. Range 0 to 8. + Persist + 1 + Type + S32 + Value + 4 + + RenderLensFlareGhostSpacing + + Comment + Distance between consecutive ghosts. Smaller packs them near the sun; larger spreads them across the screen. Range 0.1 to 1. + Persist + 1 + Type + F32 + Value + 0.3 + + RenderLensFlareGlow + + Comment + Intensity of the soft central glow around the sun. 0 disables the glow; 1 is neutral; higher boosts it. Range 0 to 5. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderLensFlareGlowFalloff + + Comment + Softness of the central glow. Lower values give a gentle, spread-out bloom; higher values keep it tight and punchy. Range 1 to 30. + Persist + 1 + Type + F32 + Value + 8.0 + + RenderLensFlareGlowRadius + + Comment + Size of the soft glow bloom directly around the sun. Small values hug the light; larger values create a wider halo of brightness. Range 0.01 to 0.5. + Persist + 1 + Type + F32 + Value + 0.12 + + RenderLensFlareHalo + + Comment + Intensity of the halo ring opposite the sun. 0 disables the halo; 1 is neutral; higher boosts it. Range 0 to 5. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderLensFlareHaloRadius + + Comment + Radius of the halo ring that appears opposite the sun through screen center. Shape control only; use RenderLensFlareHalo to toggle the effect. Range 0.01 to 1. + Persist + 1 + Type + F32 + Value + 0.5 + + RenderLensFlareHaloWidth + + Comment + Thickness of the halo ring. Smaller is a crisp hoop; larger is a soft diffuse ring. Range 0.01 to 0.5. + Persist + 1 + Type + F32 + Value + 0.15 + + RenderLensFlareOcclusionRadius + + Comment + How wide the sun-occlusion test samples the scene depth. Larger values let thin geometry (foliage, wires) dim the flare more gracefully. Range 0.005 to 0.1. + Persist + 1 + Type + F32 + Value + 0.02 + + RenderLensFlareOcclusionTaps + + Comment + Number of depth samples for sun-occlusion testing. Higher values give smoother partial occlusion transitions at a small performance cost. Range 1 to 32. + Persist + 1 + Type + S32 + Value + 9 + + RenderLensFlareStarburst + + Comment + Intensity of the starburst (diffraction-spike) pattern radiating from the sun, like aperture blades in a camera. 0 disables it. Range 0 to 5. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderLensFlareStarburstLength + + Comment + Length of the starburst spikes. 0 is short and tight to the sun; 1 lets them reach far across the screen. Range 0 to 1. + Persist + 1 + Type + F32 + Value + 0.25 + + RenderLensFlareStarburstSharpness + + Comment + How crisp the starburst spikes look. Low values produce soft, wide rays; high values produce thin, needle-like beams. Range 1 to 256. + Persist + 1 + Type + F32 + Value + 24.0 + + 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. + Persist + 1 + Type + S32 + Value + 4 + + RenderLensFlareStreakFalloff + + Comment + How quickly the streak fades toward its tips. Lower values leave a longer visible tail; higher values make the streak fade in close to the sun. Range 0.1 to 10. + Persist + 1 + Type + F32 + Value + 1.5 + + RenderLensFlareStreakIntensity + + Comment + Brightness multiplier for just the streak, separate from the master strength. 1 is neutral; higher boosts the streak without affecting the glow/ghosts. Range 0 to 5. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderLensFlareStreakLength + + Comment + How far the horizontal anamorphic streak stretches across the screen. 0.5 reaches halfway; 1.0 crosses the full width. Range 0.01 to 2. + Persist + 1 + Type + F32 + Value + 0.5 + + RenderLensFlareStreakThickness + + Comment + Vertical thickness of the streak as a fraction of the screen. Lower is a thin hairline, higher is a soft horizontal band. Range 0 to 1. + Persist + 1 + Type + F32 + Value + 0.08 + + RenderLensFlareStreakTint + + Comment + Color of the horizontal streak. The default cool blue mimics coated anamorphic cinema lenses; try warm tones for sci-fi or golden hour looks. + Persist + 1 + Type + Color3 + Value + + 0.6 + 0.7 + 1.0 + + + RenderLensFlareStrength + + Comment + Master intensity of the lens flare effect. 0 turns it off entirely; 1 is full strength. Use this as the on/off switch. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderSplitToneAmount + + Comment + Split toning strength for shadows and highlights. 0 disables the effect; 0.5 is a natural cinematic push. Tints shadows and highlights without shifting overall brightness. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderSplitToneBalance + + Comment + Slides the midpoint between shadow and highlight tints. Negative values widen shadows (good for dark scenes); positive values widen highlights (good for bright scenes). Range -1 to 1. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderSplitToneHighlightTint + + Comment + Target color for highlight tones. Neutral gray (0.5, 0.5, 0.5) is identity. Try warm amber (0.6, 0.5, 0.4) to complement teal shadows. + Persist + 1 + Type + Color3 + Value + + 0.5 + 0.5 + 0.5 + + + RenderSplitToneMidtoneAmount + + Comment + Midtone tint strength, independent from shadow/highlight amount. 0 disables; subtle values (0.15-0.35) add mood without muddying skin. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderSplitToneMidtoneTint + + Comment + Target color for midtones. Neutral gray (0.5, 0.5, 0.5) is identity. Warm tints like (0.55, 0.5, 0.45) flatter skin tones. + Persist + 1 + Type + Color3 + Value + + 0.5 + 0.5 + 0.5 + + + RenderSplitToneShadowTint + + Comment + Target color for shadow tones. Neutral gray (0.5, 0.5, 0.5) is identity. Try cool teal (0.35, 0.5, 0.55) for classic teal-and-orange looks. + Persist + 1 + Type + Color3 + Value + + 0.5 + 0.5 + 0.5 + + + RenderTonemapACESWhite + + Comment + Point at which HDR colors cross over into being pure white + Persist + 1 + Type + F32 + Value + 6.0 + + RenderTonemapAgxContrast + + Comment + Contrast of tonemapped colors + Persist + 1 + Type + F32 + Value + 1.3 + + RenderTonemapAgxWhite + + Comment + Point at which HDR colors cross over into being pure white + Persist + 1 + Type + F32 + Value + 16.29 + + RenderTonemapFilmicWhite + + Comment + Point at which HDR colors cross over into being pure white + Persist + 1 + Type + F32 + Value + 6.0 + + RenderTonemapMix + + Comment + Mix between linear and tonemapped colors (0.0(Linear) - 1.0(Tonemapped) + Persist + 1 + Type + F32 + Value + 0.7 + + RenderTonemapReinhardWhite + + Comment + Point at which HDR colors cross over into being pure white + Persist + 1 + Type + F32 + Value + 6.0 + + RenderUseExposureSkySettings + + Comment + Use exposure sky settings instead of deriving from HDR scale. + Persist + 1 + Type + Boolean + Value + 0 + + RenderVignetteAmount + + Comment + [0, 1] Opacity of applied vignette effect + Persist + 1 + Type + F32 + Value + 0.22 + + RenderVignetteCenter + + Comment + [-0.5, 0.5] Offset of vignette center x and y, z component unused + Persist + 1 + Type + Vector3 + Value + + 0.0 + 0.0 + 0.0 + + + RenderVignetteColor + + Comment + Vignette edge color + Persist + 1 + Type + Color3 + Value + + 0.0 + 0.0 + 0.0 + + + RenderVignetteCorrectAspect + + Comment + Whether the vignette effect is corrected for aspect ratio + Persist + 1 + Type + Boolean + Value + 0 + + RenderVignetteFeather + + Comment + [0.2, 4.0] controls the shape of the darkening curve from edge to center. <1 = spreads darkening inward (gentle haze across frame) 1 = linear smoothstep falloff (current behavior) >1 = concentrates darkening at edges (sharp corner darkening only) + Persist + 1 + Type + F32 + Value + 0.7 + + RenderVignetteMidColor + + Comment + Intermediate ramp color of vignette + Persist + 1 + Type + Color3 + Value + + 0.0 + 0.0 + 0.0 + + + RenderVignetteMidPoint + + Comment + [0, 1] Mid point of vignette color shift for three-color ramp - 0 disables (two-color fallback) + Persist + 1 + Type + F32 + Value + 0.0 + + RenderVignetteRadius + + Comment + [0.25, 1.5] Measured in image-height units. 1.0 reaches top/bottom edges on any aspect. + Persist + 1 + Type + F32 + Value + 1.05 + + RenderVignetteShape + + Comment + [0, 1] 0 circular, 1 rounded square. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderVignetteSoft + + Comment + [0, 1] Softness of the vignette edge + Persist + 1 + Type + F32 + Value + 0.5 + + + diff --git a/indra/newview/app_settings/settings.xml b/indra/newview/app_settings/settings.xml index de0524fbfd..1e63cd2b67 100644 --- a/indra/newview/app_settings/settings.xml +++ b/indra/newview/app_settings/settings.xml @@ -10041,7 +10041,7 @@ Comment Use exposure sky settings instead of deriving from HDR scale. Persist - 0 + 1 Type Boolean Value @@ -10052,12 +10052,45 @@ Comment Luminance coefficient for dynamic exposure Persist - 0 + 1 Type F32 Value 0.5 + RenderDynamicExposureEnabled + + Comment + Enable dynamic exposure adjustment (auto-exposure) when HDR rendering is enabled + Persist + 1 + Type + Boolean + Value + 1 + + RenderDynamicExposureSpeedError + + Comment + Speed at which dynamic exposure adapts while far from the target exposure + Persist + 1 + Type + F32 + Value + 0.1 + + RenderDynamicExposureSpeedTarget + + Comment + Speed at which dynamic exposure settles once near the target exposure + Persist + 1 + Type + F32 + Value + 2.0 + RenderDiffuseLuminanceScale Comment diff --git a/indra/newview/app_settings/settings_alchemy.xml b/indra/newview/app_settings/settings_alchemy.xml index c758927012..939b0f016b 100644 --- a/indra/newview/app_settings/settings_alchemy.xml +++ b/indra/newview/app_settings/settings_alchemy.xml @@ -1195,6 +1195,105 @@ Value 1 + AlchemyScopeLayout + + Comment + Scopes floater: how many scopes are shown at once and how they are arranged. 0 = one filling the window, 1 = two side by side, 2 = two stacked, 3 = four in a grid. Which scope each pane shows is AlchemyScopePane0 through AlchemyScopePane3; right-click a pane to set it. + Persist + 1 + Type + S32 + Value + 0 + + AlchemyScopePane0 + + Comment + Scopes floater: which scope the first pane shows. 0 = RGB histogram, 1 = luminance, 2 = red, 3 = green, 4 = blue, 5 = vectorscope, 6 = waveform luminance, 7 = waveform RGB, 8 = parade. This is the pane a single-pane layout shows. + Persist + 1 + Type + S32 + Value + 0 + + AlchemyScopePane1 + + Comment + Scopes floater: which scope the second pane shows. Same values as AlchemyScopePane0. Used by the two-pane and four-pane layouts. + Persist + 1 + Type + S32 + Value + 7 + + AlchemyScopePane2 + + Comment + Scopes floater: which scope the third pane shows. Same values as AlchemyScopePane0. Used by the four-pane layout only. + Persist + 1 + Type + S32 + Value + 8 + + AlchemyScopePane3 + + Comment + Scopes floater: which scope the fourth pane shows. Same values as AlchemyScopePane0. Used by the four-pane layout only. + Persist + 1 + Type + S32 + Value + 5 + + AlchemyScopeLogScale + + Comment + Scopes floater: plot histogram bins on a log scale so tall bins do not flatten the rest of the distribution onto the axis. 0 = linear. + Persist + 1 + Type + Boolean + Value + 1 + + AlchemyScopeSampleInterval + + Comment + Scopes floater: seconds between frame samples (0.1 = ten per second, which is about what hardware scopes run at). Lower costs more; 0 samples every frame. Only applies while a scopes floater is open. + Persist + 1 + Type + F32 + Value + 0.1 + + AlchemyScopeSampleWidth + + Comment + Scopes floater: width in pixels of the point-sampled copy the histogram is measured from; height follows the world view's aspect. Range 32 to 1024. Raising it only reduces per-bin noise (which falls as 1/sqrt(samples)) at a linear cost in read-back and binning; 320 gives roughly 7 percent jitter per bin at 16:9. + Persist + 1 + Type + S32 + Value + 320 + + AlchemyScopeSmoothing + + Comment + Scopes floater: how far each sample moves the displayed histogram towards the new measurement, 0 to 1. 1 shows the raw sample and shimmers; lower settles but lags a change in the scene. + Persist + 1 + Type + F32 + Value + 0.5 + AlchemyRenderUBOUpdateMode Comment @@ -1488,6 +1587,28 @@ Value 0 + PresetLooksActive + + Comment + Name of the currently applied Lightbox Look; blank once settings drift from it + Persist + 1 + Type + String + Value + + + PresetLooksLastApplied + + Comment + Name of the most recently applied Lightbox Look, kept for revert + Persist + 1 + Type + String + Value + + RenderAvatarShadowDetail Comment @@ -1896,7 +2017,7 @@ RenderColorGradeGamma Comment - Midtone gamma (R,G,B): bends the middle of the tonal range without moving blacks or whites. Below 1 brightens mids, above 1 darkens them. Per-channel range 0.5 to 1.5. Default (1,1,1). Try (0.9,0.9,0.9) open mids, (1.1,1.0,0.95) warm mids. + Midtone gamma (R,G,B): bends the middle of the tonal range without moving blacks or whites. Above 1 brightens mids, below 1 darkens them. Per-channel range 0.5 to 1.5. Default (1,1,1). Try (1.1,1.1,1.1) open mids, (1.1,1.0,0.95) warm mids. Persist 1 Type @@ -2122,6 +2243,28 @@ Value 0 + RenderReferenceWipeMode + + Comment + [0, 2] How a grabbed reference still is shown: 0 = off, 1 = wipe (still left of the seam, live right), 2 = side by side. Ignored unless a still has been grabbed. Session-scoped: a still does not outlive the session, so neither should the mode. + Persist + 0 + Type + S32 + Value + 0 + + RenderReferenceWipePosition + + Comment + [0, 1] Where the wipe seam sits across the frame. Session-scoped, like RenderReferenceWipeMode. + Persist + 0 + Type + F32 + Value + 0.5 + RenderFilmGrainAmount Comment diff --git a/indra/newview/app_settings/shaders/class1/alchemy/blitWithEffectsF.glsl b/indra/newview/app_settings/shaders/class1/alchemy/blitWithEffectsF.glsl index cdf98755e8..d3f7c630f2 100644 --- a/indra/newview/app_settings/shaders/class1/alchemy/blitWithEffectsF.glsl +++ b/indra/newview/app_settings/shaders/class1/alchemy/blitWithEffectsF.glsl @@ -48,6 +48,12 @@ out vec4 frag_color; uniform sampler2D diffuseRect; // Linear Rec.709 / linear-sRGB. uniform sampler2D depthMap; +// Reference still: a grabbed frame that the live image is wiped against. +// See LLPipeline::requestReferenceStill. +uniform sampler2D uReferenceStill; +uniform int uRefWipeMode; // 0 off, 1 wipe, 2 side by side. +uniform float uRefWipePos; // Seam position, 0..1 across the frame. + // ============================================================================= // Forward Declarations // ============================================================================= @@ -63,6 +69,52 @@ vec3 applyDither(vec3 color, vec2 fragCoord); #endif vec3 applyPreview(vec3 color); +// ============================================================================= +// Reference still +// ============================================================================= +// +// Substituted for the live sample *before* the print effects below, so +// vignette, grain and dither land on both sides of the seam. That is the +// point: the comparison is then about the grade, not about the print +// 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. +vec4 sampleWithReference(vec2 uv) +{ + 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); + } + + 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)); + } + + return texture(diffuseRect, uv); +} + +// A hairline at the seam, so the eye knows which side it is looking at. Drawn +// in screen pixels rather than UV so it stays one line wide at any resolution. +vec3 applyWipeSeam(vec3 color, vec2 uv) +{ + if (uRefWipeMode == 0) + { + return color; + } + + float seam = (uRefWipeMode == 1) ? uRefWipePos : 0.5; + float width = fwidth(uv.x); + float line = 1.0 - smoothstep(0.0, width, abs(uv.x - seam)); + return mix(color, vec3(1.0), line * 0.75); +} + // ============================================================================= // Main // ============================================================================= @@ -70,7 +122,7 @@ void main() { // === DISPLAY SPACE ======================================================= - vec4 diff = texture(diffuseRect, vary_fragcoord.xy); + vec4 diff = sampleWithReference(vary_fragcoord.xy); diff.rgb = applyVignette(diff.rgb, vary_fragcoord.xy); diff.rgb = applyCVDCompensation(diff.rgb); @@ -79,6 +131,7 @@ void main() diff.rgb = applyDither(diff.rgb, gl_FragCoord.xy); #endif diff.rgb = applyPreview(diff.rgb); // debug only — no-op when uPreviewMode == 0 + diff.rgb = applyWipeSeam(diff.rgb, vary_fragcoord.xy); diff.rgb = clampHDRRange(diff.rgb); frag_color = diff; diff --git a/indra/newview/app_settings/shaders/class1/alchemy/colorGradeUtilF.glsl b/indra/newview/app_settings/shaders/class1/alchemy/colorGradeUtilF.glsl index 06dda7a0da..b1b4751c8e 100644 --- a/indra/newview/app_settings/shaders/class1/alchemy/colorGradeUtilF.glsl +++ b/indra/newview/app_settings/shaders/class1/alchemy/colorGradeUtilF.glsl @@ -151,7 +151,11 @@ uniform float uToneAmount; // [0, 1] default 0 — shadow/highlight streng vec3 applySplitToning(vec3 col) { - if (uToneAmount <= 0.0) + // Both amounts. uMidtoneAmount drives its own mix() below, so gating on + // uToneAmount alone left the midtone tint unreachable unless the user also + // raised the shadow/highlight amount -- a setting that silently did + // nothing on its own. + if (uToneAmount <= 0.0 && uMidtoneAmount <= 0.0) return col; float l = dot(col, CG_LUMA); diff --git a/indra/newview/llcolorswatch.cpp b/indra/newview/llcolorswatch.cpp index 07f8a8bec4..e98f76b49a 100644 --- a/indra/newview/llcolorswatch.cpp +++ b/indra/newview/llcolorswatch.cpp @@ -30,6 +30,8 @@ #include "llcolorswatch.h" // Linden library includes +#include "llcontrol.h" +#include "v3color.h" #include "v4color.h" #include "llwindow.h" // setCursor() @@ -301,7 +303,21 @@ void LLColorSwatchCtrl::setEnabled( bool enabled ) void LLColorSwatchCtrl::setValue(const LLSD& value) { - set(LLColor4(value), true, true); + LLColor4 color(value); + // Color3 controls carry no meaningful alpha: a 3-element array reads as + // alpha 0 through LLColor4(LLSD), and a round-trip through the picker can + // persist that stale 0 as a 4th element. Show such colors opaque. + bool opaque = value.isArray() && value.size() == 3; + if (!opaque) + { + LLControlVariable* controlp = getControlVariable(); + opaque = controlp && controlp->type() == TYPE_COL3; + } + if (opaque) + { + color.mV[VALPHA] = 1.f; + } + set(color, true, true); } ////////////////////////////////////////////////////////////////////////////// @@ -324,7 +340,17 @@ void LLColorSwatchCtrl::onColorChanged ( void* data, EColorPickOp pick_op ) if (color_changed) { subject->mColor = updatedColor; - subject->setControlValue(updatedColor.getValue()); + LLControlVariable* controlp = subject->getControlVariable(); + if (controlp && controlp->type() == TYPE_COL3) + { + // Color3 controls store three components; writing the + // swatch's four would persist a meaningless alpha. + subject->setControlValue(LLColor3(updatedColor).getValue()); + } + else + { + subject->setControlValue(updatedColor.getValue()); + } } if (pick_op == COLOR_CANCEL && subject->mOnCancelCallback) diff --git a/indra/newview/llfloaterdeleteprefpreset.cpp b/indra/newview/llfloaterdeleteprefpreset.cpp index 3d9395331d..7db9d0fd5d 100644 --- a/indra/newview/llfloaterdeleteprefpreset.cpp +++ b/indra/newview/llfloaterdeleteprefpreset.cpp @@ -52,6 +52,7 @@ bool LLFloaterDeletePrefPreset::postBuild() getChild("delete")->setCommitCallback(boost::bind(&LLFloaterDeletePrefPreset::onBtnDelete, this)); getChild("cancel")->setCommitCallback(boost::bind(&LLFloaterDeletePrefPreset::onBtnCancel, this)); LLPresetsManager::instance().setPresetListChangeCallback(boost::bind(&LLFloaterDeletePrefPreset::onPresetsListChange, this)); + LLPresetsManager::instance().setPresetListChangeLooksCallback(boost::bind(&LLFloaterDeletePrefPreset::onPresetsListChange, this)); return true; } diff --git a/indra/newview/llfloatersaveprefpreset.cpp b/indra/newview/llfloatersaveprefpreset.cpp index 170f74abd3..ba34cfd9ef 100644 --- a/indra/newview/llfloatersaveprefpreset.cpp +++ b/indra/newview/llfloatersaveprefpreset.cpp @@ -36,6 +36,42 @@ #include "llpresetsmanager.h" #include "lltrans.h" +#include + +namespace +{ + void savePresetNamed(const std::string& subdirectory, const std::string& name) + { + if (!LLPresetsManager::getInstance()->savePreset(subdirectory, name)) + { + LLSD args; + args["NAME"] = name; + LLNotificationsUtil::add("PresetNotSaved", args); + } + } + + bool presetExists(const std::string& subdirectory, const std::string& name) + { + std::list names; + LLPresetsManager::getInstance()->loadPresetNamesFromDir(subdirectory, names, DEFAULT_HIDE); + + // Case-insensitively, because the comparison stands in for the + // filesystem that will do the overwrite, and on Windows and macOS that + // filesystem does not care about case: saving "sunset" over + // "Sunset.xml" replaces it. Matching exactly here would let that pair + // slip past the confirmation. On Linux this warns for a name that + // would actually coexist, which errs the survivable way round. + std::string wanted(name); + LLStringUtil::toLower(wanted); + return std::any_of(names.begin(), names.end(), + [&wanted](std::string existing) + { + LLStringUtil::toLower(existing); + return existing == wanted; + }); + } +} + LLFloaterSavePrefPreset::LLFloaterSavePrefPreset(const LLSD &key) : LLFloater(key) { @@ -57,6 +93,7 @@ bool LLFloaterSavePrefPreset::postBuild() getChild("cancel")->setCommitCallback(boost::bind(&LLFloaterSavePrefPreset::onBtnCancel, this)); LLPresetsManager::instance().setPresetListChangeCallback(boost::bind(&LLFloaterSavePrefPreset::onPresetsListChange, this)); + LLPresetsManager::instance().setPresetListChangeLooksCallback(boost::bind(&LLFloaterSavePrefPreset::onPresetsListChange, this)); mSaveButton = getChild("save"); mPresetCombo = getChild("preset_combo"); @@ -76,8 +113,27 @@ void LLFloaterSavePrefPreset::onOpen(const LLSD& key) { mSubdirectory = key.asString(); - EDefaultOptions option = DEFAULT_HIDE; - LLPresetsManager::getInstance()->setPresetNamesInComboBox(mSubdirectory, mPresetCombo, option); + std::string title_type = std::string("title_") + mSubdirectory; + if (hasString(title_type)) + { + setTitle(getString(title_type)); + } + + if (PRESETS_LOOKS == mSubdirectory) + { + // A clean name field for Looks: prefilled names invite silent + // overwrites because the combo's text entry autocompletes typed names + // to existing items. Overwriting deliberately is what the Save button + // on the Lightbox bar is for. + mPresetCombo->removeall(); + mPresetCombo->clear(); + mPresetCombo->setEnabled(true); + } + else + { + EDefaultOptions option = DEFAULT_HIDE; + LLPresetsManager::getInstance()->setPresetNamesInComboBox(mSubdirectory, mPresetCombo, option); + } onPresetNameEdited(); } @@ -92,19 +148,57 @@ void LLFloaterSavePrefPreset::onBtnSave() if ((name == LLTrans::getString(PRESETS_DEFAULT)) || (upper_name == PRESETS_DEFAULT_UPPER)) { LLNotificationsUtil::add("DefaultPresetNotSaved"); + closeFloater(); + return; } - else if (!LLPresetsManager::getInstance()->savePreset(mSubdirectory, name)) + + // Ask before replacing a Look. Every other kind of preset reaches this + // floater with the existing names already in the combo, so a collision is + // visible before the click is made; Looks deliberately start from an empty + // field (see onOpen), which is the thing that made a silent overwrite + // possible. Refusing outright the way the camera presets do is not an + // option here either -- the Lightbox's own Save button only ever + // overwrites the *active* Look, so this is the only route to replacing any + // other one. + if (PRESETS_LOOKS == mSubdirectory && presetExists(mSubdirectory, name)) { LLSD args; args["NAME"] = name; - LLNotificationsUtil::add("PresetNotSaved", args); + + // Everything the save needs is captured by value, and the floater + // itself by handle: the answer arrives whenever the user gets round to + // it, and this floater may well be gone by then. + LLHandle handle = getHandle(); + const std::string subdirectory = mSubdirectory; + LLNotificationsUtil::add("LightBoxLookOverwrite", args, LLSD(), + [handle, subdirectory, name](const LLSD& notification, const LLSD& response) + { + if (LLNotificationsUtil::getSelectedOption(notification, response) != 0) + { + // Cancelled -- leave the floater up so the name can be changed. + return; + } + + savePresetNamed(subdirectory, name); + if (LLFloater* self = handle.get()) + { + self->closeFloater(); + } + }); + return; } + savePresetNamed(mSubdirectory, name); closeFloater(); } void LLFloaterSavePrefPreset::onPresetsListChange() { + if (PRESETS_LOOKS == mSubdirectory) + { + // Looks keep a clean name field; don't repopulate over typed text. + return; + } EDefaultOptions option = DEFAULT_HIDE; LLPresetsManager::getInstance()->setPresetNamesInComboBox(mSubdirectory, mPresetCombo, option); } diff --git a/indra/newview/llfloatersettingsdebug.cpp b/indra/newview/llfloatersettingsdebug.cpp index a820cee148..600b150db2 100644 --- a/indra/newview/llfloatersettingsdebug.cpp +++ b/indra/newview/llfloatersettingsdebug.cpp @@ -479,7 +479,7 @@ void LLFloaterSettingsDebug::updateControl(LLControlVariable* controlp) LLColor3 clr; clr.setValue(sd); mColorSwatch->setVisible(true); - mColorSwatch->setValue(sd); + mColorSwatch->setValue(clr.getValue()); break; } // [RLVa:KB] - Patch: RLVa-2.1.0 diff --git a/indra/newview/llpresetsmanager.cpp b/indra/newview/llpresetsmanager.cpp index a58ea0e739..320330098c 100644 --- a/indra/newview/llpresetsmanager.cpp +++ b/indra/newview/llpresetsmanager.cpp @@ -45,9 +45,12 @@ LLPresetsManager::LLPresetsManager() { + copyDefaultLooks(); + // Connect preset signals startWatching(PRESETS_GRAPHIC); startWatching(PRESETS_CAMERA); + startWatching(PRESETS_LOOKS); } LLPresetsManager::~LLPresetsManager() @@ -64,6 +67,12 @@ LLPresetsManager::~LLPresetsManager() signal.disconnect(); } mCameraChangedSignals.clear(); + + for (auto& signal : mLooksChangedSignals) + { + signal.disconnect(); + } + mLooksChangedSignals.clear(); } void LLPresetsManager::triggerChangeCameraSignal() @@ -76,6 +85,11 @@ void LLPresetsManager::triggerChangeSignal() mPresetListChangeSignal(); } +void LLPresetsManager::triggerChangeLooksSignal() +{ + mPresetListChangeLooksSignal(); +} + void LLPresetsManager::createMissingDefault(const std::string& subdirectory) { @@ -112,6 +126,99 @@ void LLPresetsManager::createCameraDefaultPresets() } } +void LLPresetsManager::copyDefaultLooks() +{ + // Seed each bundled Look once ever, and remember which ones. + // + // This used to skip the whole step as soon as the user's directory held any + // .xml at all. That kept the property that matters -- a Look you delete + // stays deleted -- but it also meant a Look bundled in a later release + // could never reach anybody who had ever saved one of their own, which is + // to say anybody who uses the feature. + // + // Recording the names instead keeps both. Nothing is ever copied over a + // file that already exists, so an edited starter Look is safe too. + const std::string user_dir = getPresetsDir(PRESETS_LOOKS); + const std::string app_dir = gDirUtilp->getExpandedFilename(LL_PATH_APP_SETTINGS, PRESETS_LOOKS); + const std::string record = gDirUtilp->getExpandedFilename(LL_PATH_USER_SETTINGS, SEEDED_LOOKS_FILE); + + // Deliberately not inside the looks directory: anything named *.xml in + // there is enumerated as a Look. + LLSD seeded; + const bool have_record = LLFile::isfile(record); + if (have_record) + { + llifstream in(record); + if (in.is_open()) + { + LLSDSerialize::fromXML(seeded, in); + } + } + if (!seeded.isMap()) + { + seeded = LLSD::emptyMap(); + } + + // Upgrading with Looks already present means this user has been through the + // old first-run seeding. Adopt every bundled name as already done rather + // than copying: whatever they deleted back then stays deleted, and only + // names bundled after this point will ever seed for them. + bool adopt_only = false; + if (!have_record) + { + LLDirIterator user_iter(user_dir, "*.xml"); + std::string existing; + adopt_only = user_iter.next(existing); + } + + bool changed = false; + std::string file; + LLDirIterator app_iter(app_dir, "*.xml"); + while (app_iter.next(file)) + { + if (seeded.has(file)) + { + continue; + } + + if (!adopt_only && !LLFile::isfile(gDirUtilp->add(user_dir, file))) + { + LL_INFOS("Presets") << "Seeding bundled Look '" << file << "'" << LL_ENDL; + if (!LLFile::copy(gDirUtilp->add(app_dir, file), gDirUtilp->add(user_dir, file))) + { + // Recording a copy that did not happen would burn the name + // forever -- seeding is once-per-name by design, so there + // would be no second chance. Leave it unrecorded and the next + // startup simply tries again. + LL_WARNS("Presets") << "Could not seed bundled Look '" << file + << "'; leaving it for the next run" << LL_ENDL; + continue; + } + } + + seeded.insert(file, true); + changed = true; + } + + if (changed) + { + llofstream out(record.c_str()); + if (out.is_open()) + { + LLPointer formatter = new LLSDXMLFormatter(); + formatter->format(seeded, out, LLSDFormatter::OPTIONS_PRETTY); + out.close(); + } + else + { + // Not fatal, but say so: without the record the next startup will + // re-adopt rather than re-seed, so a newly bundled Look is missed + // rather than duplicated. + LL_WARNS("Presets") << "Could not write the seeded-Looks record at " << record << LL_ENDL; + } + } +} + void LLPresetsManager::startWatching(const std::string& subdirectory) { if (PRESETS_CAMERA == subdirectory) @@ -173,6 +280,36 @@ void LLPresetsManager::startWatching(const std::string& subdirectory) } } } + else if (PRESETS_LOOKS == subdirectory) + { + std::vector name_list; + getLooksControlNames(name_list); + + for (const std::string& ctrl_name : name_list) + { + if (gSavedSettings.controlExists(ctrl_name)) + { + LLPointer cntrl_ptr = gSavedSettings.getControl(ctrl_name); + if (cntrl_ptr.isNull()) + { + // Cannot happen while controlExists and getControl agree; + // guarded anyway, exactly as the camera and graphics + // branches above guard it. + LL_WARNS("Presets") << "Unable to set signal on Looks control '" << ctrl_name << "'" << LL_ENDL; + } + else + { + mLooksChangedSignals.push_back(cntrl_ptr->getCommitSignal()->connect(boost::bind(&LLPresetsManager::looksSettingChanged, this))); + } + } + else + { + // Loud on purpose: a renamed setting must not silently fall + // out of the Looks whitelist. + LL_WARNS("Presets") << "Looks control does not exist: '" << ctrl_name << "'" << LL_ENDL; + } + } + } } std::string LLPresetsManager::getPresetsDir(const std::string& subdirectory) @@ -192,6 +329,7 @@ void LLPresetsManager::loadPresetNamesFromDir(const std::string& subdirectory, p { bool IS_CAMERA = (PRESETS_CAMERA == subdirectory); bool IS_GRAPHIC = (PRESETS_GRAPHIC == subdirectory); + bool IS_LOOKS = (PRESETS_LOOKS == subdirectory); std::string dir = LLPresetsManager::getInstance()->getPresetsDir(subdirectory); LL_INFOS("AppInit") << "Loading list of preset names from " << dir << LL_ENDL; @@ -250,9 +388,18 @@ void LLPresetsManager::loadPresetNamesFromDir(const std::string& subdirectory, p } } } + if (IS_LOOKS) + { + mPresetNames.push_back(name); + } } } + if (IS_LOOKS) + { + mPresetNames.sort(LLStringUtil::precedesDict); + } + if (IS_CAMERA) { mPresetNames.sort(LLStringUtil::precedesDict); @@ -278,6 +425,17 @@ void LLPresetsManager::graphicsSettingChanged() } } +void LLPresetsManager::looksSettingChanged() +{ + static LLCachedControl looks_preset_active(gSavedSettings, "PresetLooksActive", ""); + if (!looks_preset_active().empty() && !mIgnoreChangedSignal) + { + gSavedSettings.setString("PresetLooksActive", ""); + + triggerChangeLooksSignal(); + } +} + void LLPresetsManager::cameraSettingChanged() { static LLCachedControl preset_camera_active(gSavedSettings, "PresetCameraActive", ""); @@ -341,10 +499,144 @@ void LLPresetsManager::getCameraControlNames(std::vector& names) names = camera_controls; } +void LLPresetsManager::getLooksControlNames(std::vector& names) +{ + // The single source of truth for what a Look carries. Aesthetic-only by + // design: scene/performance/quality keys belong to graphics presets, and + // session-scoped (Persist=0) or buffer-shape keys would create phantom + // dirty state. Any new aesthetic setting exposed in panel_lightbox_look + // or panel_lightbox_lens MUST be added here; Scene-tab keys must not be. + // Applying a Look writes only keys on this list, so a shared Look file + // cannot smuggle unrelated settings. + // + // Static: the list never changes, and callers include per-save and + // per-apply paths, so build the ~110 strings once rather than every call. + static const std::vector looks_controls = { + // Exposure & tone + "RenderExposure", + "AlchemyRenderTonemapType", + "RenderTonemapMix", + "RenderTonemapACESWhite", + "RenderTonemapReinhardWhite", + "RenderTonemapFilmicWhite", + "RenderTonemapAgxContrast", + "RenderTonemapAgxWhite", + "RenderDynamicExposureEnabled", + "RenderDynamicExposureCoefficient", + "RenderDynamicExposureSpeedError", + "RenderDynamicExposureSpeedTarget", + "RenderUseExposureSkySettings", + // Color LUT + "RenderColorGrade", + "RenderColorGradeLUT", + "RenderColorGradeLUTStrength", + // Basic grade, white balance, curves + "RenderColorGradeBrightness", + "RenderColorGradeContrast", + "RenderColorGradeSaturation", + "RenderColorGradeVibrance", + "RenderColorGradeHighlights", + "RenderColorGradeShadows", + "RenderColorGradeHueShift", + "RenderColorGradeBlackPoint", + "RenderColorGradeWhitePoint", + "RenderColorGradeWhiteBalanceCCT", + "RenderColorGradeWhiteBalanceDuv", + "RenderColorGradeLift", + "RenderColorGradeGamma", + "RenderColorGradeGain", + "RenderColorGradeCurveToe", + "RenderColorGradeCurveShoulder", + "RenderColorGradeCurveStrength", + // Split toning + "RenderSplitToneAmount", + "RenderSplitToneBalance", + "RenderSplitToneShadowTint", + "RenderSplitToneHighlightTint", + "RenderSplitToneMidtoneTint", + "RenderSplitToneMidtoneAmount", + // HDR bloom aesthetics (not the structural mip/resolution knobs) + "RenderBloomStrength", + "RenderBloomThreshold", + "RenderBloomKnee", + "RenderBloomScatter", + "RenderBloomAlphaGlowBoost", + "RenderBloomHalation", + "RenderBloomHalationStrength", + "RenderBloomHalationTint", + // Legacy glow (minus RenderGlowResolutionPow, owned by graphics presets) + "RenderGlow", + "RenderGlowStrength", + "RenderGlowWidth", + "RenderGlowIterations", + "RenderGlowLumWeights", + "RenderGlowMaxExtractAlpha", + "RenderGlowMinLuminance", + "RenderGlowHDR", + "RenderGlowWarmthAmount", + "RenderGlowWarmthWeights", + "RenderGlowNoise", + // Lens flare + "RenderLensFlareStrength", + "RenderLensFlareStreakLength", + "RenderLensFlareStreakFalloff", + "RenderLensFlareStreakThickness", + "RenderLensFlareStreakIntensity", + "RenderLensFlareStreakTint", + "RenderLensFlareChromaticSpread", + "RenderLensFlareGlow", + "RenderLensFlareGlowRadius", + "RenderLensFlareGlowFalloff", + "RenderLensFlareGhost", + "RenderLensFlareGhostCount", + "RenderLensFlareGhostSpacing", + "RenderLensFlareHalo", + "RenderLensFlareHaloRadius", + "RenderLensFlareHaloWidth", + "RenderLensFlareStarburst", + "RenderLensFlareStarburstSpikes", + "RenderLensFlareStarburstSharpness", + "RenderLensFlareStarburstLength", + "RenderLensFlareOcclusionRadius", + "RenderLensFlareOcclusionTaps", + // Chromatic aberration + "RenderChromaticAberrationStrength", + "RenderChromaticAberrationFalloff", + "RenderChromaticAberrationAngle", + "RenderChromaticAberrationAnisotropy", + "RenderChromaticAberrationOffsetRX", + "RenderChromaticAberrationOffsetRY", + "RenderChromaticAberrationOffsetBX", + "RenderChromaticAberrationOffsetBY", + // Vignette + "RenderVignetteAmount", + "RenderVignetteCenter", + "RenderVignetteColor", + "RenderVignetteCorrectAspect", + "RenderVignetteFeather", + "RenderVignetteMidColor", + "RenderVignetteMidPoint", + "RenderVignetteRadius", + "RenderVignetteShape", + "RenderVignetteSoft", + // Film grain + "RenderFilmGrainAmount", + "RenderFilmGrainAnimated", + "RenderFilmGrainRange", + "RenderFilmGrainSize", + "RenderFilmGrainStyle", + "RenderFilmGrainTint", + // Sharpen + "RenderCASSharpness", + }; + names = looks_controls; +} + bool LLPresetsManager::savePreset(const std::string& subdirectory, std::string name, bool createDefault) { bool IS_CAMERA = (PRESETS_CAMERA == subdirectory); bool IS_GRAPHIC = (PRESETS_GRAPHIC == subdirectory); + bool IS_LOOKS = (PRESETS_LOOKS == subdirectory); if (LLTrans::getString(PRESETS_DEFAULT) == name) { @@ -382,6 +674,14 @@ bool LLPresetsManager::savePreset(const std::string& subdirectory, std::string n getCameraControlNames(name_list); name_list.push_back("PresetCameraActive"); } + else if (IS_LOOKS) + { + // The active-name tracking settings are deliberately not embedded in + // the file: Looks are shared as files, and the filtered apply sets + // them explicitly. + name_list.clear(); + getLooksControlNames(name_list); + } else { LL_ERRS() << "Invalid presets directory '" << subdirectory << "'" << LL_ENDL; @@ -498,6 +798,14 @@ bool LLPresetsManager::savePreset(const std::string& subdirectory, std::string n // signal interested parties triggerChangeCameraSignal(); } + + if (IS_LOOKS) + { + gSavedSettings.setString("PresetLooksActive", name); + gSavedSettings.setString("PresetLooksLastApplied", name); + // signal interested parties + triggerChangeLooksSignal(); + } } else { @@ -592,6 +900,67 @@ void LLPresetsManager::loadPreset(const std::string& subdirectory, std::string n } } +bool LLPresetsManager::loadLooksPreset(std::string name) +{ + std::string full_path(getPresetsDir(PRESETS_LOOKS) + gDirUtilp->getDirDelimiter() + LLURI::escape(name) + ".xml"); + + llifstream preset_file(full_path.c_str()); + if (!preset_file.is_open()) + { + LL_WARNS("Presets") << "Cannot open Look '" << name << "' at '" << full_path << "'" << LL_ENDL; + return false; + } + + LLSD params; + LLSDSerialize::fromXML(params, preset_file); + preset_file.close(); + if (!params.isMap() || params.size() == 0) + { + LL_WARNS("Presets") << "Look '" << name << "' is not a valid preset file" << LL_ENDL; + return false; + } + + // Whitelist-filtered apply: only recognized aesthetic keys are ever + // written, so a shared Look file cannot alter unrelated settings. + std::vector allowed; + getLooksControlNames(allowed); + + S32 applied = 0; + mIgnoreChangedSignal = true; + for (const std::string& ctrl_name : allowed) + { + if (!params.has(ctrl_name)) + { + continue; + } + const LLSD& entry = params[ctrl_name]; + if (!entry.isMap() || !entry.has("Value")) + { + continue; + } + LLControlVariable* ctrl = gSavedSettings.getControl(ctrl_name).get(); + if (ctrl) + { + ctrl->set(entry["Value"]); + ++applied; + } + } + mIgnoreChangedSignal = false; + + if (applied == 0) + { + LL_WARNS("Presets") << "Look '" << name << "' contained no applicable settings" << LL_ENDL; + return false; + } + + LL_DEBUGS("Presets") << "Applied Look '" << name << "'; " << applied << " settings" << LL_ENDL; + gSavedSettings.setString("PresetLooksActive", name); + gSavedSettings.setString("PresetLooksLastApplied", name); + triggerChangeLooksSignal(); + + return true; +} + bool LLPresetsManager::deletePreset(const std::string& subdirectory, std::string name) { if (LLTrans::getString(PRESETS_DEFAULT) == name) @@ -635,6 +1004,20 @@ bool LLPresetsManager::deletePreset(const std::string& subdirectory, std::string triggerChangeCameraSignal(); } + if(PRESETS_LOOKS == subdirectory) + { + if (gSavedSettings.getString("PresetLooksActive") == name) + { + gSavedSettings.setString("PresetLooksActive", ""); + } + if (gSavedSettings.getString("PresetLooksLastApplied") == name) + { + gSavedSettings.setString("PresetLooksLastApplied", ""); + } + // signal interested parties + triggerChangeLooksSignal(); + } + return sts; } @@ -683,3 +1066,8 @@ boost::signals2::connection LLPresetsManager::setPresetListChangeCallback(const { return mPresetListChangeSignal.connect(cb); } + +boost::signals2::connection LLPresetsManager::setPresetListChangeLooksCallback(const preset_list_signal_t::slot_type& cb) +{ + return mPresetListChangeLooksSignal.connect(cb); +} diff --git a/indra/newview/llpresetsmanager.h b/indra/newview/llpresetsmanager.h index 01ba1c2909..602d3a5f52 100644 --- a/indra/newview/llpresetsmanager.h +++ b/indra/newview/llpresetsmanager.h @@ -37,6 +37,12 @@ static const std::string PRESETS_DEFAULT_UPPER = "DEFAULT"; static const std::string PRESETS_DIR = "presets"; static const std::string PRESETS_GRAPHIC = "graphic"; static const std::string PRESETS_CAMERA = "camera"; +static const std::string PRESETS_LOOKS = "looks"; +/// Which bundled Looks have already been copied into the user's directory, so a +/// Look added in a later release still reaches an existing user while one they +/// deleted stays deleted. Lives in the user settings root and NOT in the looks +/// directory, because anything named *.xml in there is enumerated as a Look. +static const std::string SEEDED_LOOKS_FILE = "looks_seeded.xml"; static const std::string PRESETS_REAR = "Rear"; static const std::string PRESETS_FRONT = "Front"; static const std::string PRESETS_SIDE = "Side"; @@ -67,11 +73,20 @@ class LLPresetsManager final : public LLSingleton void startWatching(const std::string& subdirectory); void triggerChangeCameraSignal(); void triggerChangeSignal(); + void triggerChangeLooksSignal(); static std::string getPresetsDir(const std::string& subdirectory); bool setPresetNamesInComboBox(const std::string& subdirectory, LLComboBox* combo, EDefaultOptions default_option); void loadPresetNamesFromDir(const std::string& subdirectory, preset_name_list_t& presets, EDefaultOptions default_option); bool savePreset(const std::string& subdirectory, std::string name, bool createDefault = false); void loadPreset(const std::string& subdirectory, std::string name); + // Looks apply only whitelisted keys from the file (never a raw + // loadFromFile), so shared Look files cannot carry unrelated settings. + bool loadLooksPreset(std::string name); + // The single source of truth for what a Look carries. Public because the + // Lightbox's undo stack watches exactly this list: a setting worth saving + // into a Look is a setting worth undoing, and sharing the list means a new + // grading control joins both at once rather than one and not the other. + void getLooksControlNames(std::vector& names); bool deletePreset(const std::string& subdirectory, std::string name); void createCameraDefaultPresets(); @@ -89,12 +104,14 @@ class LLPresetsManager final : public LLSingleton // Emitted when a preset gets loaded, deleted, or saved. boost::signals2::connection setPresetListChangeCameraCallback(const preset_list_signal_t::slot_type& cb); boost::signals2::connection setPresetListChangeCallback(const preset_list_signal_t::slot_type& cb); + boost::signals2::connection setPresetListChangeLooksCallback(const preset_list_signal_t::slot_type& cb); // Emitted when a preset gets loaded or saved. preset_name_list_t mPresetNames; preset_list_signal_t mPresetListChangeCameraSignal; preset_list_signal_t mPresetListChangeSignal; + preset_list_signal_t mPresetListChangeLooksSignal; private: LOG_CLASS(LLPresetsManager); @@ -103,9 +120,12 @@ class LLPresetsManager final : public LLSingleton void getCameraControlNames(std::vector& names); void graphicsSettingChanged(); void cameraSettingChanged(); + void looksSettingChanged(); + void copyDefaultLooks(); std::vector mGraphicsChangedSignals; std::vector mCameraChangedSignals; + std::vector mLooksChangedSignals; bool mIgnoreChangedSignal = false; }; diff --git a/indra/newview/llviewerfloaterreg.cpp b/indra/newview/llviewerfloaterreg.cpp index e4b68b6733..a44f7b207d 100644 --- a/indra/newview/llviewerfloaterreg.cpp +++ b/indra/newview/llviewerfloaterreg.cpp @@ -45,6 +45,7 @@ #include "alfloaterparticleeditor.h" #include "alfloaterprofilelegacy.h" #include "alfloaterprogressview.h" +#include "alfloaterscopes.h" #include "alfloaterregiontracker.h" #include "alfloatertransactionlog.h" #include "alfloaterwebprofile.h" @@ -595,6 +596,7 @@ void LLViewerFloaterReg::registerFloaters() LLFloaterReg::add("group_profile", "floater_al_group_profile.xml", (LLFloaterBuildFunc)&LLFloaterReg::build); LLFloaterReg::add("legacy_profile", "floater_al_profile_legacy.xml", (LLFloaterBuildFunc)&LLFloaterReg::build); LLFloaterReg::add("lightbox", "floater_lightbox_settings.xml", (LLFloaterBuildFunc) &LLFloaterReg::build); + LLFloaterReg::add("scopes", "floater_scopes.xml", (LLFloaterBuildFunc)&LLFloaterReg::build); LLFloaterReg::add("message_builder", "floater_message_builder.xml", (LLFloaterBuildFunc)&LLFloaterReg::build); LLFloaterReg::add("message_log", "floater_message_log.xml", (LLFloaterBuildFunc)&LLFloaterReg::build); LLFloaterReg::add("message_rewriter", "floater_message_rewriter.xml", (LLFloaterBuildFunc)&LLFloaterReg::build); diff --git a/indra/newview/llviewerinput.cpp b/indra/newview/llviewerinput.cpp index a904b07d8a..e99eda5e1d 100644 --- a/indra/newview/llviewerinput.cpp +++ b/indra/newview/llviewerinput.cpp @@ -48,6 +48,7 @@ #include "llfloatercamera.h" #include "llinitparam.h" #include "llselectmgr.h" +#include "pipeline.h" // [RLVa:KB] - Checked: 2021-07-29 (RLVa-1.4.4a) #include "rlvactions.h" #include "rlvhandler.h" @@ -971,6 +972,26 @@ bool voice_follow_key(EKeystate s) return false; } +bool grade_bypass_key(EKeystate s) +{ + // Hold to see the ungraded frame. Deliberately not a toggle of + // RenderColorGrade: that setting is on the Looks whitelist, so flipping it + // would mark the current Look dirty and, released at the wrong moment, + // save the comparison as the look. This is a render-time bypass with no + // persistent state behind it at all. + if (KEYSTATE_DOWN == s) + { + LLPipeline::sGradeBypass = true; + return true; + } + else if (KEYSTATE_UP == s) + { + LLPipeline::sGradeBypass = false; + return true; + } + return false; +} + bool script_trigger_lbutton(EKeystate s) { // Check for script overriding/expecting left mouse button. @@ -1084,6 +1105,10 @@ REGISTER_KEYBOARD_ACTION("teleport_to", teleport_to); REGISTER_KEYBOARD_ACTION("walk_to", walk_to); REGISTER_KEYBOARD_GLOBAL_ACTION("toggle_voice", toggle_voice); REGISTER_KEYBOARD_GLOBAL_ACTION("voice_follow_key", voice_follow_key); +// Global, because the hand is on the keyboard and the eye is on the scene +// while the Lightbox itself has focus -- which is precisely when a before and +// after is worth having. +REGISTER_KEYBOARD_GLOBAL_ACTION("grade_bypass_key", grade_bypass_key); REGISTER_KEYBOARD_ACTION(script_mouse_handler_name, script_trigger_lbutton); REGISTER_KEYBOARD_ACTION("roll_left", camera_roll_left); REGISTER_KEYBOARD_ACTION("roll_right", camera_roll_right); diff --git a/indra/newview/llviewerwindow.cpp b/indra/newview/llviewerwindow.cpp index 5a7665f38e..0cd5cc9449 100644 --- a/indra/newview/llviewerwindow.cpp +++ b/indra/newview/llviewerwindow.cpp @@ -3771,6 +3771,9 @@ void LLViewerWindow::updateUI() MASK mask = gKeyboard->currentMask(true); + static LLCachedControl focus_point_follows_pointer(gSavedSettings, "RenderFocusPointFollowsPointer", false); + static LLCachedControl focus_point_locked(gSavedSettings, "RenderFocusPointLocked", false); + if (gPipeline.hasRenderDebugMask(LLPipeline::RENDER_DEBUG_RAYCAST)) { gDebugRaycastFaceHit = -1; @@ -3785,6 +3788,18 @@ void LLViewerWindow::updateUI() gDebugRaycastParticle = gPipeline.lineSegmentIntersectParticle(gDebugRaycastStart, gDebugRaycastEnd, &gDebugRaycastParticleIntersection, NULL); } + else if (focus_point_follows_pointer && !focus_point_locked && LLPipeline::RenderDepthOfField) + { //keep the depth of field focus point under the pointer up to date. Picking is + //tool dependent and stops entirely while the pointer is over the UI, so the focus + //point needs its own raycast. cursorIntersect only writes gDebugRaycastIntersection + //when the ray hits something, so a miss holds the last focus point instead of + //throwing focus out to the far end of the ray. Rigged geometry is picked here + //because mesh avatars are the usual subject. + LLVector4a intersection; + intersection.clear(); + + cursorIntersect(-1, -1, 512.f, NULL, -1, false, true, true, false, NULL, &intersection); + } updateMouseDelta(); updateKeyboardFocus(); @@ -4837,9 +4852,10 @@ LLViewerObject* LLViewerWindow::cursorIntersect(S32 mouse_x, S32 mouse_y, F32 de LLVector3 mouse_world_start = mouse_point_global; LLVector3 mouse_world_end = mouse_point_global + mouse_direction_global * depth; - if (!LLViewerJoystick::getInstance()->getOverrideCamera()) + static LLCachedControl focus_point_follows_pointer(gSavedSettings, "RenderFocusPointFollowsPointer", false); + if (!LLViewerJoystick::getInstance()->getOverrideCamera() && !focus_point_follows_pointer) { //always set raycast intersection to mouse_world_end unless - //flycam is on (for DoF effect) + //flycam is on or the focus point is following the pointer (for DoF effect) gDebugRaycastIntersection.load3(mouse_world_end.mV); } @@ -5338,7 +5354,16 @@ bool LLViewerWindow::saveSnapshot(const std::string& filepath, S32 image_width, LL_INFOS() << "Saving snapshot to: " << filepath << LL_ENDL; LLPointer raw = new LLImageRaw; - bool success = rawSnapshot(raw, image_width, image_height, true, false, show_ui, show_hud, do_rebuild, show_balance); + // no_post is passed explicitly because it sits between do_rebuild and + // show_balance: when show_balance was added to this function the call site + // was not widened to match, so show_balance landed in the no_post slot and + // show_balance itself fell back to its default. Every snapshot saved + // through here has therefore been rendering with post-processing disabled, + // which for an HDR scene means no tonemapping at all. The layer type was + // stranded the same way, one slot further along: a depth snapshot asked + // for through here came back as colour. + bool success = rawSnapshot(raw, image_width, image_height, true, false, show_ui, show_hud, do_rebuild, + false /* no_post */, show_balance, type); if (success) { diff --git a/indra/newview/lutcube.cpp b/indra/newview/lutcube.cpp index 4c264c7274..fd926c3be9 100644 --- a/indra/newview/lutcube.cpp +++ b/indra/newview/lutcube.cpp @@ -20,10 +20,70 @@ freely, subject to the following restrictions: 3. This notice may not be removed or altered from any source distribution. */ -#include "llviewerprecompiledheaders.h" +// ALTERED SOURCE VERSION -- modified by the Alchemy Viewer Project, 2026. +// +// A .cube is a file a user downloads from the internet and points us at, so it +// is untrusted input, and the original parser treated it as though it were not. +// The changes here, in the order they bite: +// +// - `writeColor` indexed `colorCube` with no bounds check. A file with no +// LUT_3D_SIZE line leaves the vector empty and `size` zero, and the first +// data row then writes through a null-ish pointer. Now bounds-checked, and +// data rows before a size are rejected outright. +// - `std::stoi`/`std::stof` throw on malformed text and nothing caught them, +// so a corrupt file propagated an exception out of setupGradingLUT. Parsing +// now fails to an empty cube instead. +// - A cube was kept however few entries it had. The allocation is prefilled +// with the maximum value, so a truncated file produced a LUT that turned +// everything above the truncation point white. Completeness is now required. +// - Data rows were detected with `find_first_of("0123456789") == 0`, which +// rejects the leading whitespace, sign or decimal point that legal .cube +// files use. A rejected row does not just lose itself -- every later row +// shifts into its slot, so one indented line skews the whole cube. +// - `clampTripel` computed `255 * (x / (max - min))`: it never subtracted the +// domain minimum, so a non-zero DOMAIN_MIN was applied wrongly, and despite +// the name it did not clamp, so an out-of-domain value wrapped on the cast +// to unsigned char -- 1.1 became 280 became 24, turning a highlight black. +// - Quantisation truncated rather than rounded, biasing every entry down by up +// to one step. +// +// The output is also 16 bits per channel rather than 8. A .cube carries floats, +// and flattening them to 256 levels before the hardware interpolates between +// them is where LUT banding comes from. + +#include "linden_common.h" #include "lutcube.h" +#include "llfile.h" + +#include +#include +#include + +namespace +{ + /// Widest cube we will allocate. Entries are RGBA at 16 bits per channel, + /// so a side of 128 is already 16 MiB, and authoring tools emit 17, 33 or + /// 65 with the odd 96 or 128; the point is to have an upper bound at all, + /// so a garbage size cannot ask for a huge allocation. + constexpr int MAX_LUT_SIZE = 128; + constexpr int MIN_LUT_SIZE = 2; + + /// True if @a text begins something that could be a number, once leading + /// whitespace is gone. Sign and a bare decimal point both count: .cube + /// values are floats and negatives are legal on both sides of the domain. + bool startsNumber(const std::string& text) + { + if (text.empty()) + { + return false; + } + const char c = text[0]; + return (c >= '0' && c <= '9') || c == '-' || c == '+' || c == '.'; + } +} + LutCube::LutCube(const std::string& file) { llifstream cubeStream(file); @@ -33,13 +93,49 @@ LutCube::LutCube(const std::string& file) return; } + parse(cubeStream); +} + +void LutCube::parse(std::istream& stream) +{ std::string line; - while (std::getline(cubeStream, line)) + // stoi/stof throw, and every one of them is reading text we did not write. + // One catch around the whole parse is enough: `failed` is sticky and the + // completeness check below rejects a cube that stopped early anyway. + try + { + while (std::getline(stream, line)) + { + parseLine(line); + if (failed) + { + break; + } + } + } + catch (const std::exception& e) + { + LL_WARNS() << "malformed lut cube: " << e.what() << LL_ENDL; + failed = true; + } + + // A cube is all or nothing. The allocation is prefilled with the maximum + // value, so keeping a short one would silently blow out everything past + // the last entry that was read. + const bool complete = sawSize && (entryCount == size * size * size); + if (failed || !complete) { - parseLine(line); + if (!failed) + { + LL_WARNS() << "lut cube is incomplete: expected " << (size * size * size) << " entries, read " + << entryCount << LL_ENDL; + } + colorCube.clear(); + size = 0; } } + void LutCube::parseLine(std::string line) { if (line.length() == 0) @@ -52,11 +148,27 @@ void LutCube::parseLine(std::string line) } if (line.find("LUT_3D_SIZE") != std::string::npos) { + if (sawSize) + { + LL_WARNS() << "lut cube declares LUT_3D_SIZE more than once" << LL_ENDL; + failed = true; + return; + } + line = line.substr(line.find("LUT_3D_SIZE") + 11); line = skipWhiteSpace(line); size = std::stoi(line); - colorCube = std::vector(size * size * size * 4, 255); + if (size < MIN_LUT_SIZE || size > MAX_LUT_SIZE) + { + LL_WARNS() << "lut cube declares an unusable LUT_3D_SIZE of " << size << LL_ENDL; + failed = true; + size = 0; + return; + } + + sawSize = true; + colorCube = std::vector(static_cast(size) * size * size * 4, 65535); return; } if (line.find("DOMAIN_MIN") != std::string::npos) @@ -71,13 +183,31 @@ void LutCube::parseLine(std::string line) splitTripel(line, maxX, maxY, maxZ); return; } - if (line.find_first_of("0123456789") == 0) + if (startsNumber(skipWhiteSpace(line))) { - float x, y, z; - unsigned char outX, outY, outZ; + // Without a size there is nowhere to put this, and the original wrote + // it anyway. + if (!sawSize) + { + LL_WARNS() << "lut cube has data before LUT_3D_SIZE" << LL_ENDL; + failed = true; + return; + } + + float x = 0.f, y = 0.f, z = 0.f; + unsigned short outX, outY, outZ; splitTripel(line, x, y, z); + if (failed) + { + // splitTripel rejected the triple without writing the outputs. + // The cube is already condemned; carrying on would push the + // initialisers above -- or, unguarded, indeterminate values -- + // through the quantiser for nothing. + return; + } clampTripel(x, y, z, outX, outY, outZ); writeColor(currentX, currentY, currentZ, outX, outY, outZ); + ++entryCount; if (currentX != size - 1) { currentX++; @@ -108,32 +238,67 @@ std::string LutCube::skipWhiteSpace(std::string text) void LutCube::splitTripel(std::string tripel, float& x, float& y, float& z) { - tripel = skipWhiteSpace(tripel); - size_t after = tripel.find_first_of(" \n"); - x = std::stof(tripel.substr(0, after)); - tripel = tripel.substr(after); - - tripel = skipWhiteSpace(tripel); - after = tripel.find_first_of(" \n"); - y = std::stof(tripel.substr(0, after)); - tripel = tripel.substr(after); - - tripel = skipWhiteSpace(tripel); - z = std::stof(tripel); + // Read the three as a stream rather than by hand: the original split on the + // first " \n" it found, which loses a value to a tab, to runs of spaces, or + // to the trailing \r a CRLF file keeps when it is read on a platform that + // does not strip it. + std::istringstream values(tripel); + float a = 0.f, b = 0.f, c = 0.f; + if (!(values >> a >> b >> c)) + { + LL_WARNS() << "lut cube has a malformed triple: " << tripel << LL_ENDL; + failed = true; + return; + } + + // In practice the extraction above already refuses "nan" and "inf" -- the + // measured behaviour on MSVC, and what the num_get grammar implies + // elsewhere. But that is a property of the standard library's parser, not + // of this code, and a NaN that did get through would sail on: llclamp + // passes it (both comparisons are false) and the cast to unsigned short is + // undefined behaviour. Owning the rejection costs three compares. + if (!std::isfinite(a) || !std::isfinite(b) || !std::isfinite(c)) + { + LL_WARNS() << "lut cube has a non-finite value: " << tripel << LL_ENDL; + failed = true; + return; + } + + x = a; + y = b; + z = c; } -void LutCube::clampTripel(float x, float y, float z, unsigned char& outX, unsigned char& outY, unsigned char& outZ) +void LutCube::clampTripel(float x, float y, float z, unsigned short& outX, unsigned short& outY, unsigned short& outZ) { - outX = (unsigned char)(255 * (x / (maxX - minX))); - outY = (unsigned char)(255 * (y / (maxY - minY))); - outZ = (unsigned char)(255 * (z / (maxZ - minZ))); + // Map the declared domain onto 0..1, then onto the full 16-bit range. Both + // halves of the first step matter: without the subtraction a non-zero + // DOMAIN_MIN shifts every entry, and without the clamp an out-of-domain + // value wraps on the cast and a highlight comes out black. + auto quantise = [](float value, float low, float high) -> unsigned short + { + const float span = high - low; + const float unit = (span > 0.f) ? ((value - low) / span) : 0.f; + return static_cast(llclamp(unit, 0.f, 1.f) * 65535.f + 0.5f); + }; + + outX = quantise(x, minX, maxX); + outY = quantise(y, minY, maxY); + outZ = quantise(z, minZ, maxZ); } -void LutCube::writeColor(int x, int y, int z, unsigned char r, unsigned char g, unsigned char b) +void LutCube::writeColor(int x, int y, int z, unsigned short r, unsigned short g, unsigned short b) { - static const int colorSize = 4; // 4 bytes per point in the cube, rgba + static const int colorSize = 4; // 4 components per point in the cube, rgba + + const size_t locationR = ((static_cast(z) * size + y) * size + x) * colorSize; - int locationR = (((z * size) + y) * size + x) * colorSize; + if (locationR + 2 >= colorCube.size()) + { + LL_WARNS() << "lut cube entry out of range at " << x << ", " << y << ", " << z << LL_ENDL; + failed = true; + return; + } colorCube[locationR + 0] = r; colorCube[locationR + 1] = g; diff --git a/indra/newview/lutcube.h b/indra/newview/lutcube.h index 5c051b670a..a21829e820 100644 --- a/indra/newview/lutcube.h +++ b/indra/newview/lutcube.h @@ -20,17 +20,52 @@ freely, subject to the following restrictions: 3. This notice may not be removed or altered from any source distribution. */ +// ALTERED SOURCE VERSION -- modified by the Alchemy Viewer Project, 2026. +// Changes from the original: the domain offset is now applied and the values +// really are clamped; parsing is hardened against files that would previously +// have written out of bounds or thrown; and the parser can be driven from any +// stream so it can be tested without the filesystem. See lutcube.cpp. + #pragma once +#include +#include +#include + class LutCube { public: - std::vector colorCube; - int size = 0; + /// RGBA at 16 bits per channel, size^3 entries, laid out so x varies + /// fastest -- which is the order glTexSubImage3D wants. Empty if the file + /// could not be understood; callers test this and fall back rather than + /// uploading a half-built cube. + /// + /// Sixteen bits rather than eight because a .cube carries floats, and + /// quantising them to 256 levels before asking the hardware to interpolate + /// between them is exactly where LUT banding comes from. Normalised + /// integer rather than half float: the values are bounded by their own + /// domain, so every bit spent on an exponent would be wasted. + std::vector colorCube; + int size = 0; LutCube(const std::string& file); LutCube() = default; + /// Parse a .cube from any stream. The file constructor is a thin wrapper + /// over this; it exists separately so the parser can be exercised from a + /// string without touching the filesystem. + /// + /// One parse per object: nothing here resets the cursor, the size or the + /// sticky failure flag, so a second call sees the first's state (and + /// trips the duplicate-LUT_3D_SIZE check at best). Construct a fresh + /// LutCube per file, which is what every caller and test does. + /// + /// A cube is only kept if it is complete: the declared @c LUT_3D_SIZE must + /// be sane and exactly that many entries must have been read. Anything else + /// leaves @c colorCube empty. A partial cube is worse than none -- the + /// unwritten tail reads as white, which is not a subtle failure. + void parse(std::istream& stream); + private: float minX = 0.0f; float minY = 0.0f; @@ -44,14 +79,22 @@ class LutCube int currentY = 0; int currentZ = 0; - void writeColor(int x, int y, int z, unsigned char r, unsigned char g, unsigned char b); + /// Data rows accepted so far, checked against size^3 at the end. + int entryCount = 0; + /// Whether LUT_3D_SIZE has been seen, so data rows before it are rejected + /// rather than written into an unallocated cube. + bool sawSize = false; + /// Set by anything that makes the cube untrustworthy. Sticky. + bool failed = false; + + void writeColor(int x, int y, int z, unsigned short r, unsigned short g, unsigned short b); void parseLine(std::string line); // splits a tripel of floats void splitTripel(std::string tripel, float& x, float& y, float& z); - void clampTripel(float x, float y, float z, unsigned char& outX, unsigned char& outY, unsigned char& outZ); + void clampTripel(float x, float y, float z, unsigned short& outX, unsigned short& outY, unsigned short& outZ); // returns the text without leading whitespace std::string skipWhiteSpace(std::string text); diff --git a/indra/newview/pipeline.cpp b/indra/newview/pipeline.cpp index 571532a039..6826da6722 100644 --- a/indra/newview/pipeline.cpp +++ b/indra/newview/pipeline.cpp @@ -54,6 +54,7 @@ #include "llglheaders.h" #include "alsamplerstate.h" #include "aluniformbuffer.h" +#include "alwhitebalancesolver.h" #include "llrender.h" #include "llstartup.h" #include "llwindow.h" // swapBuffers() @@ -319,6 +320,9 @@ bool LLPipeline::sRenderTextures = true; // [RLVa:KB] - @setsphere bool LLPipeline::sUseDepthTexture = false; // [/RLVa:KB] +bool LLPipeline::sScopeCapture = false; +bool LLPipeline::sGradeBypass = false; +U32 LLPipeline::sGradeBypassMask = 0; // EventHost API LLPipeline listener. static LLPipelineListener sPipelineListener; @@ -1302,6 +1306,8 @@ void LLPipeline::releaseGLBuffers() releaseScreenBuffers(); releaseShadowBuffers(); + releaseScopeBuffers(); + clearReferenceStill(); gBumpImageList.destroyGL(); LLVOAvatar::resetImpostors(); @@ -1597,20 +1603,37 @@ void LLPipeline::setupGradingLUT() if (temp_exten == "cube") { LutCube lutCube(lut_path); - if (!lutCube.colorCube.empty()) + if (lutCube.colorCube.empty()) { - try - { - raw_image = new LLImageRaw(lutCube.colorCube.data(), lutCube.size * lutCube.size, lutCube.size, 4); - } - catch (const std::bad_alloc&) - { - return; - } - flip_green = false; - swap_bluegreen = false; - decode_success = true; + LL_WARNS() << "Failed to decode color grading LUT: " << lut_path << LL_ENDL; + return; } + + if (lutCube.size > gGLManager.mGLMaxTextureSize) + { + LL_WARNS() << "Color LUT of side " << lutCube.size << " exceeds the maximum texture size at path " + << lut_path << LL_ENDL; + return; + } + + // The .cube path does not go through LLImageRaw: that class is + // 8 bits per channel, and flattening a cube of floats to 256 + // levels before the hardware interpolates between them is where + // LUT banding comes from. LutCube already lays its entries out + // with x varying fastest, which is the order glTexSubImage3D + // wants, and a cube needs neither of the axis fixups an image + // strip does -- hence the two zeroes below. + mCGLutSize = LLVector4((F32)lutCube.size, 0.f, 0.f); + + mCGLut = new ALTexture3D(); + if (!mCGLut->allocate(lutCube.size, lutCube.size, lutCube.size, + GL_RGBA16, GL_RGBA, GL_UNSIGNED_SHORT, + lutCube.colorCube.data())) + { + LL_WARNS() << "Failed to allocate color grading LUT texture." << LL_ENDL; + mCGLut = nullptr; + } + return; } else { @@ -7272,6 +7295,304 @@ void LLPipeline::visualizeBuffers(LLRenderTarget* src, LLRenderTarget* dst, U32 dst->flush(); } +void LLPipeline::requestScenePixel(S32 x, S32 y, scene_pixel_cb_t callback) +{ + if (!callback) + { + return; + } + mScenePixelX = x; + mScenePixelY = y; + mScenePixelCallback = std::move(callback); + mScenePixelPending = true; +} + +void LLPipeline::serviceScenePixelProbe(LLRenderTarget* src) +{ + if (!mScenePixelPending || !src || gCubeSnapshot) + { + return; + } + + // Cleared before the callback runs, not after: the callback is free to ask + // for another sample, and swallowing that request would make a tool that + // stays armed sample exactly once. + mScenePixelPending = false; + scene_pixel_cb_t callback; + callback.swap(mScenePixelCallback); + + // glReadPixels upsets nSight, which is why every other readback in the + // tree is gated the same way. Nothing to report, so nothing is reported -- + // the caller has already been told the answer comes later, and a tool that + // simply does not fire beats one that fires with a lie in it. + if (LLRender::sNsightDebugSupport) + { + return; + } + + // The mouse arrives in scaled window coordinates; the buffer is in raw + // pixels, is only the world view rather than the whole window, and may be + // rendered at a fraction of that again under resolution scaling. All three + // conversions live in worldViewUV, in one place, because the scopes' + // cursor readout needs exactly the same walk -- and the first version of + // that one skipped the scaled-to-raw step, which is invisible on a machine + // where the two spaces happen to match. + F32 u = 0.f, v = 0.f; + if (!worldViewUV(mScenePixelX, mScenePixelY, u, v)) + { + // Clicked outside the 3D view. Nothing there to sample. + return; + } + + const S32 px = llclamp((S32)(u * (F32)src->getWidth()), 0, (S32)src->getWidth() - 1); + const S32 py = llclamp((S32)(v * (F32)src->getHeight()), 0, (S32)src->getHeight() - 1); + + F32 texel[4] = { 0.f, 0.f, 0.f, 0.f }; + src->bindTarget(); + glReadPixels(px, py, 1, 1, GL_RGBA, GL_FLOAT, texel); + src->flush(); + + // A synchronous read stalls the pipe. That is the right trade here: it + // happens once, on a click, and a PBO round trip would mean holding the + // request across frames for a result no one is waiting on but the person + // who just clicked. + callback(LLColor3(texel[0], texel[1], texel[2])); +} + +void LLPipeline::releaseScopeBuffers() +{ + if (mScopePBO[0]) + { + glDeleteBuffers(2, mScopePBO); + mScopePBO[0] = 0; + mScopePBO[1] = 0; + } + mScopePBOInFlight = -1; + mScopeSample.release(); + mScopeData.clear(); +} + +// static +bool LLPipeline::worldViewUV(S32 scaled_x, S32 scaled_y, F32& u, F32& v) +{ + // Everything here stays in scaled window space, and the answer comes out as + // a fraction of the world view -- which is all a buffer covering that view + // needs, whatever resolution it happens to be rendered at. + // + // The previous version converted the mouse to raw pixels by the *window's* + // raw/scaled ratio and then measured it against the *world view's* raw + // rect. Those are two different scalings, and mixing them leaves an error + // proportional to the distance from the origin: dead on in one corner and + // visibly adrift in the far one. Staying in one space cannot express that + // mistake. It is also what the viewer itself does when it asks whether the + // cursor is over the world (llviewerwindow.cpp, getWorldViewRectScaled + // against mCurrentMousePoint). + const LLRect world = gViewerWindow->getWorldViewRectScaled(); + if (world.getWidth() <= 0 || world.getHeight() <= 0) + { + return false; + } + + u = (F32)(scaled_x - world.mLeft) / (F32)world.getWidth(); + v = (F32)(scaled_y - world.mBottom) / (F32)world.getHeight(); + return (u >= 0.f && u <= 1.f && v >= 0.f && v <= 1.f); +} + +bool LLPipeline::getScopePixel(S32 scaled_x, S32 scaled_y, LLColor4U& out) const +{ + const S32 width = mScopeSample.getWidth(); + const S32 height = mScopeSample.getHeight(); + if (width <= 0 || height <= 0 || mScopeReadback.size() < (size_t)width * height * 4) + { + return false; + } + + F32 u = 0.f, v = 0.f; + if (!worldViewUV(scaled_x, scaled_y, u, v)) + { + return false; + } + + // The sample is a point decimation of the whole world view, so this is a + // plain rescale with no letterboxing or crop to undo. Row 0 is the bottom: + // glReadPixels fills bottom-up and the world rect counts y upwards too, so + // v == 0 is the bottom on both sides and there is no flip. + const S32 sx = llclamp((S32)(u * (F32)width), 0, width - 1); + const S32 sy = llclamp((S32)(v * (F32)height), 0, height - 1); + + const size_t at = ((size_t)sy * width + sx) * 4; + out.set(mScopeReadback[at], mScopeReadback[at + 1], mScopeReadback[at + 2], 255); + return true; +} + +void LLPipeline::clearReferenceStill() +{ + mReferenceStill.release(); + mReferenceStillWanted = false; +} + +void LLPipeline::captureReferenceStill(LLRenderTarget* src) +{ + if (!src || gCubeSnapshot) + { + return; + } + + // A still taken at one resolution cannot honestly be compared against a + // frame at another: sampled by UV it would stretch, and a reference you + // cannot trust geometrically is worse than no reference at all. Drop it + // and let the user grab again -- which is what a resize does to every + // other full-resolution target here anyway. + if (mReferenceStill.getWidth() != src->getWidth() || + mReferenceStill.getHeight() != src->getHeight()) + { + mReferenceStill.release(); + } + + if (!mReferenceStillWanted) + { + return; + } + mReferenceStillWanted = false; + + LL_PROFILE_ZONE_SCOPED_CATEGORY_PIPELINE; + LL_PROFILE_GPU_ZONE("reference still"); + + if (mReferenceStill.getWidth() == 0) + { + // GL_RGB10_A2 whether or not HDR is on. It is what the post chain uses + // in HDR and the same four bytes per pixel as the GL_RGBA8 it uses + // otherwise, so this copy is exact in the first case and lossless in + // the second, without having to track which one the source was. + if (!mReferenceStill.allocate(src->getWidth(), src->getHeight(), GL_RGB10_A2)) + { + LL_WARNS("Pipeline") << "Could not allocate the reference still target." << LL_ENDL; + mReferenceStill.release(); + return; + } + } + + mReferenceStill.copyContents(*src, 0, 0, src->getWidth(), src->getHeight(), + 0, 0, mReferenceStill.getWidth(), mReferenceStill.getHeight(), + GL_COLOR_BUFFER_BIT, GL_NEAREST); +} + +void LLPipeline::captureScopeSample(LLRenderTarget* src) +{ + if (!sScopeCapture || !src || gCubeSnapshot) + { + return; + } + + // Scopes are read, not watched. Sampling every frame would multiply the + // cost by six for a display no one can follow that fast; hardware scopes + // update at about this rate for the same reason. + static LLCachedControl interval(gSavedSettings, "AlchemyScopeSampleInterval", 0.1f); + if (mScopeSampleTimer.getStarted() && mScopeSampleTimer.getElapsedTimeF32() < llmax(interval(), 0.f)) + { + return; + } + mScopeSampleTimer.reset(); + mScopeSampleTimer.start(); + + LL_PROFILE_ZONE_SCOPED_CATEGORY_PIPELINE; + LL_PROFILE_GPU_ZONE("scope sample"); + + static LLCachedControl sample_width(gSavedSettings, "AlchemyScopeSampleWidth", 320); + const S32 width = llclamp(sample_width(), 32, 1024); + const F32 aspect = (F32)llmax(1U, src->getHeight()) / (F32)llmax(1U, src->getWidth()); + const S32 height = llclamp(ll_round((F32)width * aspect), 16, 1024); + const S32 pixels = width * height; + const size_t bytes = (size_t)pixels * 4; + + if (mScopeSample.getWidth() != (U32)width || mScopeSample.getHeight() != (U32)height) + { + // Resolution changed under us (window resize, or the debug key moved). + // Anything in flight was measured against the old size, so drop it -- + // and the pack buffers with it, since their stores were sized to that + // old frame. Left alone, a grown sample reads past the end of the old + // store on the next collect: glReadPixels into the too-small buffer is + // refused with GL_INVALID_OPERATION, and the memcpy out of the map + // then walks off its end. The block below rebuilds them at the new + // size, exactly as it built them the first time. + mScopeSample.release(); + if (mScopePBO[0]) + { + glDeleteBuffers(2, mScopePBO); + mScopePBO[0] = 0; + mScopePBO[1] = 0; + } + // The last readback was laid out at the old stride. getScopePixel + // indexes it against the target's dimensions, which are about to be + // the new ones, so a shrink would read the wrong pixel until the next + // collect. mScopeData stays: its bins are shares, still a true + // measurement of the same scene, and clearing it would blank the plot. + mScopeReadback.clear(); + if (!mScopeSample.allocate(width, height, GL_RGBA8)) + { + sScopeCapture = false; + LL_WARNS("Pipeline") << "Could not allocate the scope sample target; scopes disabled." << LL_ENDL; + return; + } + mScopePBOInFlight = -1; + } + + if (!mScopePBO[0]) + { + glGenBuffers(2, mScopePBO); + for (S32 i = 0; i < 2; ++i) + { + glBindBuffer(GL_PIXEL_PACK_BUFFER, mScopePBO[i]); + glBufferData(GL_PIXEL_PACK_BUFFER, bytes, nullptr, GL_STREAM_READ); + } + glBindBuffer(GL_PIXEL_PACK_BUFFER, 0); + mScopePBOInFlight = -1; + } + + // GL_NEAREST on a downscaling blit picks one source texel per destination + // texel: a point decimation of the frame, not an average of it. That + // distinction is the whole point. A box filter would pull every value + // toward the local mean, narrowing the histogram and erasing both tails -- + // so a small blown highlight, the thing a photographer most wants to be + // warned about, would simply vanish into its neighbours. + mScopeSample.copyContents(*src, 0, 0, src->getWidth(), src->getHeight(), + 0, 0, width, height, GL_COLOR_BUFFER_BIT, GL_NEAREST); + + const S32 next = (mScopePBOInFlight + 1) & 1; + + // Collect the previous capture first. It was issued a whole sample + // interval ago -- many frames -- so the map finds it long since landed and + // never blocks. Reading the buffer we are about to write would. + if (mScopePBOInFlight >= 0) + { + glBindBuffer(GL_PIXEL_PACK_BUFFER, mScopePBO[mScopePBOInFlight]); + if (const void* mapped = glMapBuffer(GL_PIXEL_PACK_BUFFER, GL_READ_ONLY)) + { + mScopeReadback.resize(bytes); + memcpy(mScopeReadback.data(), mapped, bytes); + glUnmapBuffer(GL_PIXEL_PACK_BUFFER); + + ALScopeData fresh; + fresh.accumulate(mScopeReadback.data(), width, height); + + // Ease towards the new measurement. A point sample of ~57k pixels + // spread over 256 bins carries visible shot noise; without this + // the plot shimmers even on a still frame. + static LLCachedControl smoothing(gSavedSettings, "AlchemyScopeSmoothing", 0.5f); + mScopeData.blendToward(fresh, llclamp(smoothing(), 0.f, 1.f)); + } + glBindBuffer(GL_PIXEL_PACK_BUFFER, 0); + } + + mScopeSample.bindTarget(); + glBindBuffer(GL_PIXEL_PACK_BUFFER, mScopePBO[next]); + glReadPixels(0, 0, width, height, GL_RGBA, GL_UNSIGNED_BYTE, nullptr); + glBindBuffer(GL_PIXEL_PACK_BUFFER, 0); + mScopeSample.flush(); + + mScopePBOInFlight = next; +} + void LLPipeline::generateLuminance(LLRenderTarget* src, LLRenderTarget* dst) { // luminance sample and mipmap generation @@ -7420,85 +7741,6 @@ void LLPipeline::generateExposure(LLRenderTarget* src, LLRenderTarget* dst, bool } } -namespace -{ - // Port of the Kim et al. 2002 Planckian-locus polynomial used by the - // original white-balance shader. Valid 1667K..25000K to ~1% of the true - // locus. Output is CIE xy chromaticity at Y = 1. - inline void cg_cct_to_xy(F32 cct, F32& out_x, F32& out_y) - { - F32 t = cct, t2 = t * t, t3 = t2 * t; - if (t <= 4000.0f) - out_x = -0.2661239e9f / t3 - 0.2343589e6f / t2 + 0.8776956e3f / t + 0.179910f; - else - out_x = -3.0258469e9f / t3 + 2.1070379e6f / t2 + 0.2226347e3f / t + 0.240390f; - - F32 x2 = out_x * out_x, x3 = x2 * out_x; - if (t <= 2222.0f) - out_y = -1.1063814f * x3 - 1.34811020f * x2 + 2.18555832f * out_x - 0.20219683f; - else if (t <= 4000.0f) - out_y = -0.9549476f * x3 - 1.37418593f * x2 + 2.09137015f * out_x - 0.16748867f; - else - out_y = 3.0817580f * x3 - 5.87338670f * x2 + 3.75112997f * out_x - 0.37001483f; - } - - // Apply a Duv offset perpendicular to the Planckian locus via a central- - // difference tangent in CIE 1960 u,v space (O(h²) accurate). Returns the - // new CIE xy chromaticity. - inline void cg_apply_duv(F32 cct, F32 duv, F32& out_x, F32& out_y) - { - F32 xA, yA, xB, yB; - cg_cct_to_xy(cct - 1.0f, xA, yA); - cg_cct_to_xy(cct + 1.0f, xB, yB); - - F32 dA = -2.0f * xA + 12.0f * yA + 3.0f; - F32 uA = 4.0f * xA / dA; - F32 vA = 6.0f * yA / dA; - F32 dB = -2.0f * xB + 12.0f * yB + 3.0f; - F32 uB = 4.0f * xB / dB; - F32 vB = 6.0f * yB / dB; - - F32 uMid = 0.5f * (uA + uB); - F32 vMid = 0.5f * (vA + vB); - F32 tx = uB - uA; - F32 ty = vB - vA; - F32 tlen = sqrtf(tx * tx + ty * ty); - if (tlen > 1e-12f) { tx /= tlen; ty /= tlen; } - - F32 u = uMid + (-ty) * duv; // perp = (-tangent.y, tangent.x) - F32 v = vMid + ( tx) * duv; - - F32 dBack = 2.0f * u - 8.0f * v + 4.0f; - out_x = 3.0f * u / dBack; - out_y = 2.0f * v / dBack; - } - - // Resolve the artist's (CCT offset, Duv) pair into a linear-sRGB gain, - // normalised so green pins to 1 (preserves luminance). Duv sign is - // flipped to match the tint convention: +Duv pushes green, -Duv magenta. - inline LLVector3 cg_compute_white_balance_gain(F32 cct_offset, F32 duv) - { - if (fabsf(cct_offset) < 1e-3f && fabsf(duv) < 1e-5f) - return LLVector3(1.f, 1.f, 1.f); - - constexpr F32 D65_CCT = 6504.0f; - F32 target_cct = llclamp(D65_CCT + cct_offset, 1667.0f, 25000.0f); - - F32 x, y; - cg_apply_duv(target_cct, -duv, x, y); - - F32 X = x / y; - F32 Y = 1.0f; - F32 Z = (1.0f - x - y) / y; - - F32 r = 3.2404542f * X - 1.5371385f * Y - 0.4985314f * Z; - F32 g = -0.9692660f * X + 1.8760108f * Y + 0.0415560f * Z; - F32 b = 0.0556434f * X - 0.2040259f * Y + 1.0572252f * Z; - - F32 inv_g = 1.0f / llmax(g, 1e-6f); - return LLVector3(r * inv_g, 1.0f, b * inv_g); - } -} void LLPipeline::colorCorrect(LLRenderTarget* src, LLRenderTarget* dst, bool apply_tonemap, bool apply_color_grade) { @@ -7515,7 +7757,19 @@ void LLPipeline::colorCorrect(LLRenderTarget* src, LLRenderTarget* dst, bool app LLSettingsSky::ptr_t psky = LLEnvironment::instance().getCurrentSky(); - bool color_grade = apply_color_grade && color_grade_cc; + // sGradeBypass is the hold-to-compare key. Both shader variants are + // already built and bound by this same branch, so suppressing the + // grade is a rebind rather than a recompile -- free, and instant on + // the frame the key goes down. + // gSnapshotNoPost is the snapshot floater's "No post-processing" box, + // and it only ever selected a non-tonemap variant -- leaving the grade, + // which is the most post-processing thing in the whole chain, running + // on a frame that had asked for none of it. + // + // legacy_gamma deliberately does not do this. A legacy sky drops + // tonemapping because it predates it, not because anybody asked for a + // clean plate, and the user's grade still applies there. + bool color_grade = apply_color_grade && color_grade_cc && !sGradeBypass && !gSnapshotNoPost; bool legacy_gamma = psky->getReflectionProbeAmbiance(should_auto_adjust) == 0.f; bool no_post = gSnapshotNoPost || legacy_gamma || (buildNoPost && gFloaterTools && gFloaterTools->isAvailable()); LLGLSLShader* shader = nullptr; @@ -7571,6 +7825,14 @@ void LLPipeline::colorCorrect(LLRenderTarget* src, LLRenderTarget* dst, bool app shader->uniform2f(LLShaderMgr::SCREEN_RESOLUTION, (GLfloat)src->getWidth(), (GLfloat)src->getHeight()); + // The other half of the "No post-processing" contract. The grade is + // gated where this pass's program is chosen and the print effects in + // the final blit are gated in renderFinalize -- but chromatic + // aberration and the lens flare are applied *inside* this program, in + // every variant including the no-post ones, so their strengths have to + // be silenced here or a clean plate still carries both. + const bool clean_plate = gSnapshotNoPost; + // Chromatic aberration parameters static LLCachedControl chromatic_aberration_strength(gSavedSettings, "RenderChromaticAberrationStrength", 0.f); static LLCachedControl chromatic_aberration_falloff(gSavedSettings, "RenderChromaticAberrationFalloff", 1.f); @@ -7584,7 +7846,7 @@ void LLPipeline::colorCorrect(LLRenderTarget* src, LLRenderTarget* dst, bool app // strength (and pre-apply the 0.02 peak-offset scale), take the // reciprocal of falloff, and turn the angle into a (sin, cos) pair. // Saves one mul, one div, and one sincos per fragment. - F32 ca_strength = llclamp(chromatic_aberration_strength(), 0.f, 1.f); + F32 ca_strength = clean_plate ? 0.f : llclamp(chromatic_aberration_strength(), 0.f, 1.f); F32 ca_falloff = llclamp(chromatic_aberration_falloff(), 0.5f, 4.f); F32 ca_angle_rad = llclamp(chromatic_aberration_angle(), 0.f, 360.f) * 0.01745329252f; shader->uniform1f(LLShaderMgr::CA_AMOUNT, ca_strength * ca_strength * 0.02f); @@ -7619,7 +7881,9 @@ void LLPipeline::colorCorrect(LLRenderTarget* src, LLRenderTarget* dst, bool app static LLCachedControl lens_flare_occlusion_radius(gSavedSettings, "RenderLensFlareOcclusionRadius", 0.02f); static LLCachedControl lens_flare_occlusion_taps(gSavedSettings, "RenderLensFlareOcclusionTaps", 9); - F32 strength = llclamp(lens_flare_strength(), 0.f, 1.f); + // Zeroing the master strength both hits the shader's early-out and + // skips the whole detail block below, sun projection included. + F32 strength = clean_plate ? 0.f : llclamp(lens_flare_strength(), 0.f, 1.f); shader->uniform1f(LLShaderMgr::LENS_FLARE_STRENGTH, strength); if (strength > 0.f) @@ -7814,7 +8078,22 @@ void LLPipeline::colorCorrect(LLRenderTarget* src, LLRenderTarget* dst, bool app S32 cglut_channel = -1; if (color_grade) { - if (mCGLut.notNull()) + // Per-section bypass. Uploading a group's identity rather than its + // settings lands in the early-out the shader already has for that + // step, so a bypassed group is cheaper than an active one and no + // variant or recompile is involved. The settings themselves are + // untouched, which is the whole point -- see sGradeBypassMask. + const U32 bypass = sGradeBypassMask; + const bool skip_basic = (bypass & GRADE_BYPASS_BASIC) != 0; + const bool skip_primaries = (bypass & GRADE_BYPASS_PRIMARIES) != 0; + const bool skip_split = (bypass & GRADE_BYPASS_SPLIT) != 0; + const bool skip_lut = (bypass & GRADE_BYPASS_LUT) != 0; + const bool skip_curve = (bypass & GRADE_BYPASS_CURVE) != 0; + + static const F32 IDENTITY_ZEROS[3] = { 0.f, 0.f, 0.f }; + static const F32 IDENTITY_ONES[3] = { 1.f, 1.f, 1.f }; + + if (mCGLut.notNull() && !skip_lut) { cglut_channel = shader->getTextureChannel(LLShaderMgr::COLOR_GRADE_LUT); if (cglut_channel > -1) @@ -7834,14 +8113,14 @@ void LLPipeline::colorCorrect(LLRenderTarget* src, LLRenderTarget* dst, bool app // --- Linear-space grading (pre-tonemap) --- // White balance: resolve (CCT offset, Duv) into a linear-sRGB gain - // on the CPU. Duv is exposed to artists on a friendly [-1, 1] - // scale and scaled into CIE 1960 uv units (±0.02) here. + // on the CPU. The map lives in ALWhiteBalanceSolver so the + // eyedropper can invert the same function this uploads -- range + // clamping and the Duv-to-uv scale included, since a solver that + // disagreed about either would return a pair this then altered. static LLCachedControl cg_wb_cct(gSavedSettings, "RenderColorGradeWhiteBalanceCCT", 0.f); static LLCachedControl cg_wb_duv(gSavedSettings, "RenderColorGradeWhiteBalanceDuv", 0.f); - const F32 wb_cct = llclamp((F32)cg_wb_cct(), -5000.0f, 5000.0f); - const F32 wb_duv = llclamp((F32)cg_wb_duv(), -1.0f, 1.0f) * 0.02f; - const LLVector3 wb_gain = cg_compute_white_balance_gain(wb_cct, wb_duv); - shader->uniform3fv(LLShaderMgr::COLOR_GRADE_WHITE_BALANCE_GAIN, 1, wb_gain.mV); + const LLVector3 wb_gain = ALWhiteBalanceSolver::gain((F32)cg_wb_cct(), (F32)cg_wb_duv()); + shader->uniform3fv(LLShaderMgr::COLOR_GRADE_WHITE_BALANCE_GAIN, 1, skip_basic ? IDENTITY_ONES : wb_gain.mV); // Lift / Gamma / Gain — gamma is inverted on the CPU. static LLCachedControl cg_lift(gSavedSettings, "RenderColorGradeLift", LLVector3(0.f, 0.f, 0.f)); @@ -7862,9 +8141,9 @@ void LLPipeline::colorCorrect(LLRenderTarget* src, LLRenderTarget* dst, bool app 1.0f / llclamp(gamma_v.mV[0], 0.5f, 1.5f), 1.0f / llclamp(gamma_v.mV[1], 0.5f, 1.5f), 1.0f / llclamp(gamma_v.mV[2], 0.5f, 1.5f) }; - shader->uniform3fv(LLShaderMgr::COLOR_GRADE_LIFT, 1, lift_arr); - shader->uniform3fv(LLShaderMgr::COLOR_GRADE_INV_GAMMA_CC, 1, inv_gamma_arr); - shader->uniform3fv(LLShaderMgr::COLOR_GRADE_GAIN, 1, gain_arr); + shader->uniform3fv(LLShaderMgr::COLOR_GRADE_LIFT, 1, skip_primaries ? IDENTITY_ZEROS : lift_arr); + shader->uniform3fv(LLShaderMgr::COLOR_GRADE_INV_GAMMA_CC, 1, skip_primaries ? IDENTITY_ONES : inv_gamma_arr); + shader->uniform3fv(LLShaderMgr::COLOR_GRADE_GAIN, 1, skip_primaries ? IDENTITY_ONES : gain_arr); // --- Split toning --- // Tints → ratios: `tint / max(dot(tint, LUMA), 1e-4)` precomputed. @@ -7892,9 +8171,9 @@ void LLPipeline::colorCorrect(LLRenderTarget* src, LLRenderTarget* dst, bool app shader->uniform3fv(LLShaderMgr::SPLIT_TONE_SHADOW_RATIO, 1, shadow_ratio); shader->uniform3fv(LLShaderMgr::SPLIT_TONE_HIGHLIGHT_RATIO, 1, highlight_ratio); shader->uniform3fv(LLShaderMgr::SPLIT_TONE_MIDTONE_RATIO, 1, midtone_ratio); - shader->uniform1f(LLShaderMgr::SPLIT_TONE_MIDTONE_AMOUNT, llclamp(split_midtone_amount(), 0.0f, 1.0f)); + shader->uniform1f(LLShaderMgr::SPLIT_TONE_MIDTONE_AMOUNT, skip_split ? 0.f : llclamp(split_midtone_amount(), 0.0f, 1.0f)); shader->uniform1f(LLShaderMgr::SPLIT_TONE_MID, 0.5f + tone_balance * 0.4f); - shader->uniform1f(LLShaderMgr::SPLIT_TONE_AMOUNT, llclamp(split_amount(), 0.0f, 1.0f)); + shader->uniform1f(LLShaderMgr::SPLIT_TONE_AMOUNT, skip_split ? 0.f : llclamp(split_amount(), 0.0f, 1.0f)); // --- Display-space grading --- // Every slider is folded into a {scale, bias} pair on the CPU @@ -7918,15 +8197,15 @@ void LLPipeline::colorCorrect(LLRenderTarget* src, LLRenderTarget* dst, bool app const F32 contrast = llclamp(cg_contrast(), 0.0f, 2.0f); const F32 bc_scale = contrast; const F32 bc_bias = (brightness - 0.5f) * contrast + 0.5f; - shader->uniform1f(LLShaderMgr::COLOR_GRADE_BWP_SCALE, bwp_scale); - shader->uniform1f(LLShaderMgr::COLOR_GRADE_BWP_BIAS, bwp_bias); - shader->uniform1f(LLShaderMgr::COLOR_GRADE_BC_SCALE, bc_scale); - shader->uniform1f(LLShaderMgr::COLOR_GRADE_BC_BIAS, bc_bias); - shader->uniform1f(LLShaderMgr::COLOR_GRADE_HIGHLIGHTS_SCALED, llclamp(cg_highlights(), -1.0f, 1.0f) * 0.3f); - shader->uniform1f(LLShaderMgr::COLOR_GRADE_SHADOWS_SCALED, llclamp(cg_shadows(), -1.0f, 1.0f) * 0.3f); - shader->uniform1f(LLShaderMgr::COLOR_GRADE_SATURATION, llclamp(cg_saturation(), 0.0f, 2.0f)); - shader->uniform1f(LLShaderMgr::COLOR_GRADE_VIBRANCE, llclamp(cg_vibrance(), -1.0f, 1.0f)); - shader->uniform1f(LLShaderMgr::COLOR_GRADE_HUE_SHIFT_NORM, llclamp(cg_hue_shift(), -180.0f, 180.0f) / 360.0f); + shader->uniform1f(LLShaderMgr::COLOR_GRADE_BWP_SCALE, skip_basic ? 1.f : bwp_scale); + shader->uniform1f(LLShaderMgr::COLOR_GRADE_BWP_BIAS, skip_basic ? 0.f : bwp_bias); + shader->uniform1f(LLShaderMgr::COLOR_GRADE_BC_SCALE, skip_basic ? 1.f : bc_scale); + shader->uniform1f(LLShaderMgr::COLOR_GRADE_BC_BIAS, skip_basic ? 0.f : bc_bias); + shader->uniform1f(LLShaderMgr::COLOR_GRADE_HIGHLIGHTS_SCALED, skip_basic ? 0.f : llclamp(cg_highlights(), -1.0f, 1.0f) * 0.3f); + shader->uniform1f(LLShaderMgr::COLOR_GRADE_SHADOWS_SCALED, skip_basic ? 0.f : llclamp(cg_shadows(), -1.0f, 1.0f) * 0.3f); + shader->uniform1f(LLShaderMgr::COLOR_GRADE_SATURATION, skip_basic ? 1.f : llclamp(cg_saturation(), 0.0f, 2.0f)); + shader->uniform1f(LLShaderMgr::COLOR_GRADE_VIBRANCE, skip_basic ? 0.f : llclamp(cg_vibrance(), -1.0f, 1.0f)); + shader->uniform1f(LLShaderMgr::COLOR_GRADE_HUE_SHIFT_NORM, skip_basic ? 0.f : llclamp(cg_hue_shift(), -180.0f, 180.0f) / 360.0f); // Per-channel filmic curves. Per-channel `(shoulder - toe)` is // pre-inverted on the CPU so the shader avoids three divisions. @@ -7946,7 +8225,7 @@ void LLPipeline::colorCorrect(LLRenderTarget* src, LLRenderTarget* dst, bool app llclamp(curve_strength.mV[2], 0.0f, 1.0f) }; shader->uniform3fv(LLShaderMgr::COLOR_GRADE_CURVE_TOE, 1, curve_toe.mV); shader->uniform3fv(LLShaderMgr::COLOR_GRADE_CURVE_INV_RANGE, 1, curve_inv_range); - shader->uniform3fv(LLShaderMgr::COLOR_GRADE_CURVE_STRENGTH, 1, curve_strength_arr); + shader->uniform3fv(LLShaderMgr::COLOR_GRADE_CURVE_STRENGTH, 1, skip_curve ? IDENTITY_ZEROS : curve_strength_arr); } mScreenTriangleVB->setBuffer(); @@ -8660,7 +8939,7 @@ void LLPipeline::renderDoF(LLRenderTarget* src, LLRenderTarget* dst) if (focus_point.isExactlyZero()) { - if (LLViewerJoystick::getInstance()->getOverrideCamera() || RenderFocusPointFollowsPointer) + if (LLViewerJoystick::getInstance()->getOverrideCamera() || RenderFocusPointFollowsPointer) { // focus on point under cursor focus_point.set(gDebugRaycastIntersection.getF32ptr()); } @@ -8669,7 +8948,7 @@ void LLPipeline::renderDoF(LLRenderTarget* src, LLRenderTarget* dst) LLVector4a result; result.clear(); - gViewerWindow->cursorIntersect(-1, -1, 512.f, nullptr, -1, false, false, true, true, nullptr, nullptr, nullptr, &result); + gViewerWindow->cursorIntersect(-1, -1, 512.f, nullptr, -1, false, false, true, true, nullptr, &result); focus_point.set(result.getF32ptr()); } @@ -8861,6 +9140,13 @@ void LLPipeline::renderFinalize() 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. + serviceScenePixelProbe(&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. @@ -8965,6 +9251,15 @@ void LLPipeline::renderFinalize() } } + // Measure what is about to be presented. After every post pass, before + // the vignette/grain/dither the final blit adds -- those are print + // effects, deliberately outside the measurement. + captureScopeSample(sourceBuffer); + + // Same point, same reason: a still and the scopes must agree about what a + // frame is, or the numbers will not describe the picture beside them. + captureReferenceStill(sourceBuffer); + // Present the screen target. { LL_PROFILE_ZONE_NAMED_CATEGORY_PIPELINE("renderFinalize - final blit"); @@ -8981,6 +9276,17 @@ void LLPipeline::renderFinalize() gBlitWithEffectsProgram.uniform2f(LLShaderMgr::SCREEN_RESOLUTION, (F32)gViewerWindow->getWorldViewRectRaw().getWidth(), (F32)gViewerWindow->getWorldViewRectRaw().getHeight()); + // The effects below had no snapshot gate at all, so "No post-processing" + // produced an image that was still vignetted, still grainy, still + // CVD-compensated and, with a preview mode on, still false-coloured. + // They are the print effects; a clean plate is exactly the frame + // without them. + // + // Dither is not in the list on purpose. It is a quantisation aid rather + // than a look, and an 8-bit PNG wants it whether or not the rest is + // wanted. + const bool clean_plate = gSnapshotNoPost; + // Vignette static LLCachedControl vignette_amount(gSavedSettings, "RenderVignetteAmount", 0.0f, "[0, 1] default 0."); static LLCachedControl vignette_radius(gSavedSettings, "RenderVignetteRadius", 1.0f); @@ -8992,7 +9298,7 @@ void LLPipeline::renderFinalize() static LLCachedControl vignette_center(gSavedSettings, "RenderVignetteCenter", LLVector3(0.0f, 0.0f, 0.0f)); static LLCachedControl vignette_correct_aspect(gSavedSettings, "RenderVignetteCorrectAspect", false); static LLCachedControl vignette_feather(gSavedSettings, "RenderVignetteFeather", 1.0f); - gBlitWithEffectsProgram.uniform1f(LLShaderMgr::VIGNETTE_AMOUNT, llclamp(vignette_amount(), 0.0f, 1.0f)); + gBlitWithEffectsProgram.uniform1f(LLShaderMgr::VIGNETTE_AMOUNT, clean_plate ? 0.0f : llclamp(vignette_amount(), 0.0f, 1.0f)); gBlitWithEffectsProgram.uniform1f(LLShaderMgr::VIGNETTE_RADIUS, llclamp(vignette_radius(), 0.25f, 1.5f)); gBlitWithEffectsProgram.uniform1f(LLShaderMgr::VIGNETTE_SOFT, llclamp(vignette_soft(), 0.05f, 1.0f)); gBlitWithEffectsProgram.uniform1f(LLShaderMgr::VIGNETTE_SHAPE, llclamp(vignette_shape(), 0.0f, 1.0f)); @@ -9013,8 +9319,8 @@ void LLPipeline::renderFinalize() // CVD Compensation static LLCachedControl cvd_mode(gSavedSettings, "RenderCVDMode", 0); static LLCachedControl cvd_amount(gSavedSettings, "RenderCVDAmount", 0.0f); - gBlitWithEffectsProgram.uniform1i(LLShaderMgr::CVD_MODE, llclamp(cvd_mode(), 0, 3)); - gBlitWithEffectsProgram.uniform1f(LLShaderMgr::CVD_AMOUNT, llclamp(cvd_amount(), 0.0f, 1.0f)); + gBlitWithEffectsProgram.uniform1i(LLShaderMgr::CVD_MODE, clean_plate ? 0 : llclamp(cvd_mode(), 0, 3)); + gBlitWithEffectsProgram.uniform1f(LLShaderMgr::CVD_AMOUNT, clean_plate ? 0.0f : llclamp(cvd_amount(), 0.0f, 1.0f)); // Film Grain static LLCachedControl film_grain_animated(gSavedSettings, "RenderFilmGrainAnimated", true); @@ -9023,7 +9329,7 @@ void LLPipeline::renderFinalize() static LLCachedControl film_grain_size(gSavedSettings, "RenderFilmGrainSize", 1.0f); static LLCachedControl film_grain_range(gSavedSettings, "RenderFilmGrainRange", 0.5f); static LLCachedControl film_grain_tint(gSavedSettings, "RenderFilmGrainTint", LLColor3(1.0f, 1.0f, 1.0f)); - gBlitWithEffectsProgram.uniform1f(LLShaderMgr::GRAIN_AMOUNT, llclamp(film_grain_amount(), 0.0f, 1.0f)); + gBlitWithEffectsProgram.uniform1f(LLShaderMgr::GRAIN_AMOUNT, clean_plate ? 0.0f : llclamp(film_grain_amount(), 0.0f, 1.0f)); gBlitWithEffectsProgram.uniform1i(LLShaderMgr::GRAIN_STYLE, llclamp(film_grain_style(), 0, 3)); gBlitWithEffectsProgram.uniform1f(LLShaderMgr::GRAIN_SIZE, llclamp(film_grain_size(), 1.0f, 8.0f)); gBlitWithEffectsProgram.uniform1f(LLShaderMgr::GRAIN_RANGE, llclamp(film_grain_range(), 0.0f, 1.0f)); @@ -9039,7 +9345,23 @@ void LLPipeline::renderFinalize() // Previews static LLCachedControl preview_mode(gSavedSettings, "RenderEffectPreviewMode", 0); - gBlitWithEffectsProgram.uniform1i(LLShaderMgr::PREVIEW_MODE, llclamp(preview_mode(), 0, 7)); + gBlitWithEffectsProgram.uniform1i(LLShaderMgr::PREVIEW_MODE, clean_plate ? 0 : llclamp(preview_mode(), 0, 7)); + + // Reference still. The mode is forced off unless a still actually + // exists, which is what lets the shader read the sampler without + // checking, and means a mode left set from before a resize (which + // drops the still) shows the live frame rather than a stale or unbound + // texture. Off for a clean plate too: a snapshot of a comparison is + // not a snapshot of the image. + static LLCachedControl ref_wipe_mode(gSavedSettings, "RenderReferenceWipeMode", 0); + static LLCachedControl ref_wipe_pos(gSavedSettings, "RenderReferenceWipePosition", 0.5f); + const S32 wipe_mode = (hasReferenceStill() && !clean_plate) ? llclamp(ref_wipe_mode(), 0, 2) : 0; + gBlitWithEffectsProgram.uniform1i(LLShaderMgr::REFERENCE_WIPE_MODE, wipe_mode); + gBlitWithEffectsProgram.uniform1f(LLShaderMgr::REFERENCE_WIPE_POS, llclamp(ref_wipe_pos(), 0.f, 1.f)); + if (wipe_mode != 0) + { + gBlitWithEffectsProgram.bindTexture(LLShaderMgr::REFERENCE_STILL, &mReferenceStill); + } { LLGLDepthTest depth_test(GL_TRUE, GL_TRUE, GL_ALWAYS); diff --git a/indra/newview/pipeline.h b/indra/newview/pipeline.h index 6ed2745fd9..b496aae393 100644 --- a/indra/newview/pipeline.h +++ b/indra/newview/pipeline.h @@ -45,6 +45,7 @@ #include "llrendertarget.h" #include "llreflectionmapmanager.h" #include "llheroprobemanager.h" +#include "alscopedata.h" #include #include @@ -152,6 +153,74 @@ class LLPipeline void combineGlow(LLRenderTarget* src, LLRenderTarget* dst); void visualizeBuffers(LLRenderTarget* src, LLRenderTarget* dst, U32 bufferIndex); + /// Take a point-sampled copy of the presented frame for the scopes floater + /// and read it back asynchronously. Does nothing unless sScopeCapture is + /// set and the sample interval has elapsed. See alfloaterscopes.h. + void captureScopeSample(LLRenderTarget* src); + /// Service a pending grab, and drop a still whose resolution no longer + /// matches the frame. Called from the same place as captureScopeSample. + void captureReferenceStill(LLRenderTarget* src); + const ALScopeData& getScopeData() const { return mScopeData; } + + /// Where a point in *scaled* window coordinates -- which is what + /// LLViewerWindow::getCurrentMouse and the tool mouse handlers hand you -- + /// falls inside the world view, as 0..1 across and up. False when it is + /// outside the view. + /// + /// Everything stays in scaled space, which is why this is the only place + /// that should do it: measuring a scaled point against the world view's + /// *raw* rect mixes two scalings and leaves an error proportional to the + /// distance from the origin -- dead on in one corner, adrift in the far + /// one, and invisible on a machine where the two spaces happen to match. + static bool worldViewUV(S32 scaled_x, S32 scaled_y, F32& u, F32& v); + + /// Colour of the last sampled frame under a point in *scaled* window + /// coordinates, false if nothing has been sampled there. + /// + /// Costs nothing: it reads the copy captureScopeSample already took, so + /// there is no second readback and no stall. The price is that it is only + /// as fresh and as fine as that sample -- one sample interval old, and one + /// cell of a point decimation of the view, which for "what level is this" + /// is what you want anyway. + bool getScopePixel(S32 scaled_x, S32 scaled_y, LLColor4U& out) const; + void releaseScopeBuffers(); + + /// @name Reference still + /// Resolve's still store, in miniature: freeze the frame, then wipe the + /// live image against it. This is how a colourist judges a change that + /// took longer to make than hold-to-compare can span -- that one only ever + /// shows you *no* grade, whereas this shows you the grade you had. + /// + /// The still is taken from the same point the scopes sample, so the two + /// agree about what a frame is: after every post pass, before the print + /// effects the final blit adds. Those are then applied to both sides of + /// the seam, which is what makes the comparison about the grade rather + /// than about the vignette. + /// @{ + + /// Grab the next presented frame. Honoured on that frame's blit, so the + /// still is of what the user is looking at now, not of a frame already + /// gone. + void requestReferenceStill() { mReferenceStillWanted = true; } + void clearReferenceStill(); + bool hasReferenceStill() const { return mReferenceStill.getWidth() > 0; } + /// @} + + /// Ask for the linear scene colour under a screen pixel. + /// + /// @a x and @a y are scaled window coordinates, as a mouse handler + /// receives them. The answer arrives on the next rendered frame, read out + /// of the scene buffer before white balance, grading or tonemapping have + /// touched it -- so it describes the light in the scene rather than the + /// look currently laid over it, which is what a tool deciding on the look + /// needs. Colour components are linear radiance and may exceed 1. + /// + /// One request is held at a time; asking again replaces it. The callback + /// does not fire if the click was outside the 3D view, or under a graphics + /// debugger that cannot tolerate a read back. + typedef std::function scene_pixel_cb_t; + void requestScenePixel(S32 x, S32 y, scene_pixel_cb_t callback); + void init(); void cleanup(); bool isInit() { return mInitialized; }; @@ -705,6 +774,38 @@ class LLPipeline static bool sUseDepthTexture; // [/RLVa:KB] + // Set while a scopes floater is open. Nothing is sampled or read back + // while this is false, so the feature costs exactly nothing when closed. + static bool sScopeCapture; + /// Hold-to-compare: suppresses colour grading for as long as it is set. + /// Render-time only, with nothing persistent behind it -- toggling + /// RenderColorGrade instead would mark the active Look dirty, and a key + /// released at the wrong moment could save the comparison as the look. + static bool sGradeBypass; + + /// One bit per grading group, for comparing a single section against the + /// rest of the grade rather than against nothing at all. + enum EGradeBypass : U32 + { + GRADE_BYPASS_BASIC = 1 << 0, ///< White balance, tone, presence: the Basic section + GRADE_BYPASS_PRIMARIES = 1 << 1, ///< Lift / gamma / gain + GRADE_BYPASS_SPLIT = 1 << 2, ///< Split toning + GRADE_BYPASS_LUT = 1 << 3, ///< 3D LUT + GRADE_BYPASS_CURVE = 1 << 4, ///< Tone curve + }; + + /// A set bit makes colorCorrect upload that group's identity values rather + /// than its settings, which lands in the early-out the shader already has + /// for that step -- so a bypassed group costs less than an active one, and + /// no shader variant is involved. + /// + /// Render-time only, and deliberately not a setting, for the same reason + /// sGradeBypass is not: the Looks whitelist watches every grading control, + /// so building a comparison out of one would mark the active Look dirty + /// and let the comparison itself be saved as the look. It follows that + /// whoever sets a bit owns clearing it -- see ~ALFloaterLightBox. + static U32 sGradeBypassMask; + static LLTrace::EventStatHandle sStatBatchSize; class RenderTargetPack @@ -768,6 +869,30 @@ class LLPipeline // downres scratch space for GPU downscaling of textures LLRenderTarget mDownResMap; + // Scopes floater sample. Point-decimated copy of the presented frame plus + // two pixel-pack buffers: the read-back of one capture is collected at the + // next one, several frames later, so glReadPixels never stalls the frame. + LLRenderTarget mScopeSample; + U32 mScopePBO[2] = { 0, 0 }; + S32 mScopePBOInFlight = -1; + LLFrameTimer mScopeSampleTimer; + ALScopeData mScopeData; + std::vector mScopeReadback; + + // Reference still: a grabbed copy of a presented frame, for wiping the + // live image against. Full resolution, so it is only allocated once + // somebody asks for one. + LLRenderTarget mReferenceStill; + bool mReferenceStillWanted = false; + + // One-shot scene probe, serviced in renderFinalize while the buffer is + // still linear. See requestScenePixel. + void serviceScenePixelProbe(LLRenderTarget* src); + scene_pixel_cb_t mScenePixelCallback; + S32 mScenePixelX = 0; + S32 mScenePixelY = 0; + bool mScenePixelPending = false; + // 2k bom scratch target LLRenderTarget mBakeMap; diff --git a/indra/newview/skins/default/textures/script_editor/script_save.png b/indra/newview/skins/default/textures/script_editor/script_save.png new file mode 100644 index 0000000000..016000657c Binary files /dev/null and b/indra/newview/skins/default/textures/script_editor/script_save.png differ diff --git a/indra/newview/skins/default/textures/textures.xml b/indra/newview/skins/default/textures/textures.xml index c35c1a624a..6ef77843c0 100644 --- a/indra/newview/skins/default/textures/textures.xml +++ b/indra/newview/skins/default/textures/textures.xml @@ -996,6 +996,7 @@ with the same filename but different name + diff --git a/indra/newview/skins/default/xui/en/control_table_contents_media.xml b/indra/newview/skins/default/xui/en/control_table_contents_media.xml index 5d5b6ed3ce..7e5470831a 100644 --- a/indra/newview/skins/default/xui/en/control_table_contents_media.xml +++ b/indra/newview/skins/default/xui/en/control_table_contents_media.xml @@ -83,4 +83,14 @@ name="lst_action" value="Interact (Script LMB)" /> + + + diff --git a/indra/newview/skins/default/xui/en/floater_delete_pref_preset.xml b/indra/newview/skins/default/xui/en/floater_delete_pref_preset.xml index ad724fb2aa..db22cb9593 100644 --- a/indra/newview/skins/default/xui/en/floater_delete_pref_preset.xml +++ b/indra/newview/skins/default/xui/en/floater_delete_pref_preset.xml @@ -11,6 +11,7 @@ Delete Graphic Preset Delete Camera Preset + Delete Look + width="460" + height="560" + min_width="420" + min_height="420" + layout="topleft" + name="floater_lightbox_settings" + positioning="cascading" + title="Lightbox" + save_rect="true" + can_resize="true"> + + + [NAME] * + + + + + + + + + + + + + + + + - + follows="all" + layout="topleft" + left="4" + top_pad="2" + right="-4" + height="512" + name="lightbox_tabs" + tab_position="top" + tab_min_width="70"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + filename="panel_lightbox_look.xml" + label="Look" + layout="topleft" + name="tab_look" /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + filename="panel_lightbox_lens.xml" + label="Lens" + layout="topleft" + name="tab_lens" /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + filename="panel_lightbox_scene.xml" + label="Scene" + layout="topleft" + name="tab_scene" /> + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + filename="panel_lightbox_sky.xml" + label="Sky" + layout="topleft" + name="tab_sky" /> diff --git a/indra/newview/skins/default/xui/en/floater_save_pref_preset.xml b/indra/newview/skins/default/xui/en/floater_save_pref_preset.xml index dbfbc058c0..02cbcbea5d 100644 --- a/indra/newview/skins/default/xui/en/floater_save_pref_preset.xml +++ b/indra/newview/skins/default/xui/en/floater_save_pref_preset.xml @@ -9,6 +9,10 @@ title="Save Graphic Preset" width="300"> + Save Graphic Preset + Save Camera Preset + Save Look + + + + Measuring... + + + Crushed [LOW]% Blown [HIGH]% ([SAMPLES] samples) + + + Crushed [LOW]% Blown [HIGH]% R [R] G [G] B [B] + + + RGB + Luminance + Red + Green + Blue + Vectorscope + Waveform + Waveform RGB + Parade + + + + + + + + + + + diff --git a/indra/newview/skins/default/xui/en/menu_scopes_pane.xml b/indra/newview/skins/default/xui/en/menu_scopes_pane.xml new file mode 100644 index 0000000000..fd7d37fc92 --- /dev/null +++ b/indra/newview/skins/default/xui/en/menu_scopes_pane.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/indra/newview/skins/default/xui/en/menu_viewer.xml b/indra/newview/skins/default/xui/en/menu_viewer.xml index bf6c6348fb..499dab1c1b 100644 --- a/indra/newview/skins/default/xui/en/menu_viewer.xml +++ b/indra/newview/skins/default/xui/en/menu_viewer.xml @@ -861,6 +861,16 @@ function="Floater.Show" parameter="360capture" /> + + + + + +That spot is too dark to read a colour from. Pick something well lit that should look white or grey. + + + +Temperature and tint cannot neutralise that. It looks like a coloured surface rather than a neutral one under coloured light. + + + +A Look called '[NAME]' already exists. Replace it? + +This cannot be undone. + confirm + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Applies when HDR rendering is off. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/indra/newview/skins/default/xui/en/panel_lightbox_look.xml b/indra/newview/skins/default/xui/en/panel_lightbox_look.xml new file mode 100644 index 0000000000..90fe766f3b --- /dev/null +++ b/indra/newview/skins/default/xui/en/panel_lightbox_look.xml @@ -0,0 +1,1922 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/indra/newview/skins/default/xui/en/panel_lightbox_scene.xml b/indra/newview/skins/default/xui/en/panel_lightbox_scene.xml new file mode 100644 index 0000000000..dad1a63d80 --- /dev/null +++ b/indra/newview/skins/default/xui/en/panel_lightbox_scene.xml @@ -0,0 +1,2101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/indra/newview/skins/default/xui/en/panel_lightbox_sky.xml b/indra/newview/skins/default/xui/en/panel_lightbox_sky.xml new file mode 100644 index 0000000000..00a886f6e7 --- /dev/null +++ b/indra/newview/skins/default/xui/en/panel_lightbox_sky.xml @@ -0,0 +1,402 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/indra/newview/tests/alcolorwheelmodel_test.cpp b/indra/newview/tests/alcolorwheelmodel_test.cpp new file mode 100644 index 0000000000..332209559d --- /dev/null +++ b/indra/newview/tests/alcolorwheelmodel_test.cpp @@ -0,0 +1,392 @@ +/** + * @file alcolorwheelmodel_test.cpp + * @brief Unit tests for the colour wheel's RGB <-> master/hue/saturation maths + * + * Copyright (c) 2026, Alchemy Viewer Project. + * + * The source code in this file is provided to you under the terms of the + * GNU Lesser General Public License, version 2.1, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A + * PARTICULAR PURPOSE. Terms of the LGPL can be found in doc/LGPL-licence.txt + * in this distribution, or online at http://www.gnu.org/licenses/lgpl-2.1.txt + * + */ + +#include "linden_common.h" + +#include "../test/lltut.h" + +#include "../alcolorwheelmodel.h" + +#include + +namespace tut +{ + struct wheel_data + { + ALColorWheelModel mWheel; + + /// Lift: centred on 0, per-channel -0.5 to +0.5. + void asLift() { mWheel.configure(0.f, -0.5f, 0.5f, false); } + /// Gain / gamma: centred on 1, per-channel 0.5 to 1.5. + void asGain() { mWheel.configure(1.f, 0.5f, 1.5f, false); } + /// A split-tone tint: centred on 0.5 over 0..1, master locked because + /// the renderer normalises tint magnitude away. + void asTint() { mWheel.configure(0.5f, 0.f, 1.f, true); } + + static F32 deg(F32 d) { return d * F_PI / 180.f; } + + /// Smallest absolute difference between two angles, allowing for wrap. + static F32 angleDelta(F32 a, F32 b) + { + F32 d = fmodf(fabsf(a - b), F_TWO_PI); + return (d > F_PI) ? F_TWO_PI - d : d; + } + }; + + typedef test_group wheel_group; + typedef wheel_group::object wheel_object; + tut::wheel_group wg("ALColorWheelModel"); + + // --- the basis itself ---------------------------------------------------- + + // The whole design rests on {(1,1,1)/sqrt3, u, v} being orthonormal -- that + // is what makes the decomposition lossless. A typo in the constants would + // show up here and nowhere else obvious. + template<> template<> + void wheel_object::test<1>() + { + const LLVector3 u = ALColorWheelModel::chromaDirection(0.f); + const LLVector3 v = ALColorWheelModel::chromaDirection(F_PI_BY_TWO); + const LLVector3 grey(1.f, 1.f, 1.f); + + ensure_approximately_equals("u is unit", u.magVec(), 1.f, 5); + ensure_approximately_equals("v is unit", v.magVec(), 1.f, 5); + ensure_approximately_equals("u and v are perpendicular", u * v, 0.f, 5); + ensure_approximately_equals("u has no achromatic part", u * grey, 0.f, 5); + ensure_approximately_equals("v has no achromatic part", v * grey, 0.f, 5); + } + + // Angle lands on the usual colour circle: red at 0, green at 120, blue at 240. + template<> template<> + void wheel_object::test<2>() + { + F32 hue, sat; + ALColorWheelModel::toPolar(LLVector3(1.f, 0.f, 0.f), hue, sat); + ensure_approximately_equals("pure red is hue 0", angleDelta(hue, deg(0.f)), 0.f, 4); + + ALColorWheelModel::toPolar(LLVector3(0.f, 1.f, 0.f), hue, sat); + ensure_approximately_equals("pure green is 120", angleDelta(hue, deg(120.f)), 0.f, 4); + + ALColorWheelModel::toPolar(LLVector3(0.f, 0.f, 1.f), hue, sat); + ensure_approximately_equals("pure blue is 240", angleDelta(hue, deg(240.f)), 0.f, 4); + } + + // A neutral triplet has no chroma, and asking its hue must not produce a + // NaN from atan2(0,0) -- the puck would jump the moment it crossed centre. + template<> template<> + void wheel_object::test<3>() + { + F32 hue, sat; + for (F32 grey : { -0.4f, 0.f, 0.5f, 1.f }) + { + ALColorWheelModel::toPolar(LLVector3(grey, grey, grey), hue, sat); + ensure_approximately_equals("no saturation", sat, 0.f, 5); + ensure("hue is finite", std::isfinite(hue)); + } + } + + // --- round trip ---------------------------------------------------------- + + // The property everything else depends on: decompose and rebuild returns + // the same triplet. If this fails, typing a number and dragging the puck + // disagree about the value. + template<> template<> + void wheel_object::test<4>() + { + const F32 samples[] = { -0.5f, -0.31f, -0.07f, 0.f, 0.12f, 0.29f, 0.5f }; + for (F32 r : samples) + { + for (F32 g : samples) + { + for (F32 b : samples) + { + const LLVector3 in(r, g, b); + F32 hue, sat; + ALColorWheelModel::toPolar(in, hue, sat); + const LLVector3 out = ALColorWheelModel::toRGB( + ALColorWheelModel::masterOf(in), hue, sat); + + ensure_approximately_equals("r round trips", out.mV[VX], in.mV[VX], 5); + ensure_approximately_equals("g round trips", out.mV[VY], in.mV[VY], 5); + ensure_approximately_equals("b round trips", out.mV[VZ], in.mV[VZ], 5); + } + } + } + } + + // Round trip through the stored value, which is the path the widget takes. + template<> template<> + void wheel_object::test<5>() + { + asLift(); + for (S32 i = 0; i < 24; ++i) + { + const F32 hue = deg((F32)i * 15.f); + const F32 sat = mWheel.getMaxSat() * 0.6f; + mWheel.setPolar(hue, sat); + + ensure_approximately_equals("hue survives", angleDelta(mWheel.getHue(), hue), 0.f, 4); + ensure_approximately_equals("saturation survives", mWheel.getSat(), sat, 4); + ensure_approximately_equals("master untouched", mWheel.getMaster(), 0.f, 5); + } + } + + // --- the hexagon --------------------------------------------------------- + + // getMaxSat is the inradius, so a puck on the rim is reachable at EVERY + // hue with the master centred -- nothing gets clamped. + template<> template<> + void wheel_object::test<6>() + { + asLift(); + const F32 rim = mWheel.getMaxSat(); + for (S32 i = 0; i < 72; ++i) + { + const F32 hue = deg((F32)i * 5.f); + mWheel.setPolar(hue, rim); + ensure_approximately_equals("rim is reachable at every hue", mWheel.getSat(), rim, 3); + ensure_approximately_equals("and the hue is the one asked for", + angleDelta(mWheel.getHue(), hue), 0.f, 3); + } + } + + // The inradius is sqrt(1.5) * halfRange, and the tightest directions are + // the primaries, where one channel lands exactly on its limit. + template<> template<> + void wheel_object::test<7>() + { + asLift(); + ensure_approximately_equals("inradius", mWheel.getMaxSat(), sqrtf(1.5f) * 0.5f, 4); + + mWheel.setPolar(0.f, mWheel.getMaxSat()); + ensure_approximately_equals("red sits on its limit", mWheel.getRGB().mV[VX], 0.5f, 3); + + asGain(); + ensure_approximately_equals("gain inradius", mWheel.getMaxSat(), sqrtf(1.5f) * 0.5f, 4); + } + + // Past the rim the channel clamp bites: the value stays legal, and the + // puck reports where the value actually is rather than where the pointer + // went. + // + // The ceiling here is NOT getMaxSat(). Three different radii are in play + // and it is worth being explicit about which is which, for a range of + // width w: + // + // w * 1/sqrt(6) = 0.408w inradius with the master pinned to centre + // -- what getMaxSat() returns, so the rim is + // reachable at every hue + // w * 1/sqrt(8) = 0.354w ... times 2/sqrt(3): the circumradius of that + // same hexagon, at 30, 90, 150 degrees + // w * sqrt(2/3) = 0.816w the widest deviation ANY legal triplet has + // + // Clamping moves the mean, so the result leaves the master-pinned hexagon + // and lands on the projection of the whole cube -- the third figure. A + // bound of the second is too tight and fails here. + template<> template<> + void wheel_object::test<8>() + { + asLift(); + const F32 rim = mWheel.getMaxSat(); + const F32 widest = sqrtf(2.f / 3.f) * (mWheel.getMax() - mWheel.getMin()); + + for (S32 i = 0; i < 36; ++i) + { + const F32 hue = deg((F32)i * 10.f); + mWheel.setPolar(hue, rim * 4.f); + + const LLVector3& rgb = mWheel.getRGB(); + for (S32 c = 0; c < 3; ++c) + { + ensure("channel stayed in range", rgb.mV[c] >= -0.5f - 1e-5f && rgb.mV[c] <= 0.5f + 1e-5f); + } + ensure("saturation did not run away", mWheel.getSat() <= widest + 1e-4f); + ensure("but it did move out to the boundary", mWheel.getSat() > rim * 0.9f); + } + + // Far enough past the rim in a primary direction and the triplet is + // pinned to a cube corner, which is exactly the widest case. + mWheel.setPolar(0.f, rim * 4.f); + ensure_approximately_equals("corner-pinned saturation", mWheel.getSat(), widest, 4); + ensure_approximately_equals("r on its ceiling", mWheel.getRGB().mV[VX], 0.5f, 5); + ensure_approximately_equals("g on its floor", mWheel.getRGB().mV[VY], -0.5f, 5); + } + + // Clamping must not silently rotate the hue -- the puck would slide around + // the ring while the user dragged straight outward. + template<> template<> + void wheel_object::test<9>() + { + asLift(); + for (S32 i = 0; i < 12; ++i) + { + const F32 hue = deg((F32)i * 30.f); + mWheel.setPolar(hue, mWheel.getMaxSat() * 3.f); + ensure_approximately_equals("hue held through the clamp", + angleDelta(mWheel.getHue(), hue), 0.f, 3); + } + } + + // --- master -------------------------------------------------------------- + + // Moving the master keeps the chroma, which is what makes the wheel and + // its slider feel independent. + template<> template<> + void wheel_object::test<10>() + { + asGain(); + mWheel.setPolar(deg(200.f), mWheel.getMaxSat() * 0.4f); + const F32 hue = mWheel.getHue(); + const F32 sat = mWheel.getSat(); + + mWheel.setMaster(1.2f); + ensure_approximately_equals("master moved", mWheel.getMaster(), 1.2f, 4); + ensure_approximately_equals("hue kept", angleDelta(mWheel.getHue(), hue), 0.f, 3); + ensure_approximately_equals("saturation kept", mWheel.getSat(), sat, 3); + } + + // A master pushed past its range clamps, and the triplet stays legal. + template<> template<> + void wheel_object::test<11>() + { + asGain(); + mWheel.setMaster(9.f); + ensure("master clamped", mWheel.getMaster() <= 1.5f + 1e-5f); + for (S32 c = 0; c < 3; ++c) + { + ensure("channel in range", mWheel.getRGB().mV[c] <= 1.5f + 1e-5f); + } + } + + // Tint wheels lock the master, because the renderer divides each tint by + // dot(tint, LUMA) -- magnitude cancels, so a master there would be a + // control that visibly does nothing. + template<> template<> + void wheel_object::test<12>() + { + asTint(); + mWheel.setPolar(deg(90.f), mWheel.getMaxSat() * 0.5f); + const F32 master = mWheel.getMaster(); + + mWheel.setMaster(0.9f); + ensure_approximately_equals("master ignored", mWheel.getMaster(), master, 5); + + // ... and a fresh puck move re-centres on the configured neutral. + mWheel.setPolar(deg(30.f), mWheel.getMaxSat() * 0.5f); + ensure_approximately_equals("re-centred on neutral", mWheel.getMaster(), 0.5f, 4); + } + + // --- ring / puck agreement ---------------------------------------------- + + // The ring is generated from the same basis as the puck, so the colour + // shown at an angle is the colour dragging to that angle produces. Drawing + // a generic HSV ring instead is where these two drift apart. + template<> template<> + void wheel_object::test<13>() + { + for (S32 i = 0; i < 36; ++i) + { + const F32 hue = deg((F32)i * 10.f); + const LLVector3 ring = ALColorWheelModel::ringColor(hue); + + F32 ring_hue, ring_sat; + ALColorWheelModel::toPolar(ring, ring_hue, ring_sat); + ensure_approximately_equals("ring hue matches the puck's", + angleDelta(ring_hue, hue), 0.f, 3); + ensure("ring is actually coloured", ring_sat > 0.f); + } + } + + // The ring's reference chroma is chosen to stay inside 0..1, so no channel + // is ever clipped -- clipping would bend the hue it is advertising. + template<> template<> + void wheel_object::test<14>() + { + for (S32 i = 0; i < 72; ++i) + { + const LLVector3 ring = ALColorWheelModel::ringColor(deg((F32)i * 5.f)); + for (S32 c = 0; c < 3; ++c) + { + ensure("ring channel is strictly inside the gamut", + ring.mV[c] > 0.001f && ring.mV[c] < 0.999f); + } + } + } + + // --- numeric entry and reset -------------------------------------------- + + // Typing into one field leaves the other two alone and moves the puck. + template<> template<> + void wheel_object::test<15>() + { + asLift(); + mWheel.setChannel(0, 0.2f); + ensure_approximately_equals("channel written", mWheel.getRGB().mV[VX], 0.2f, 5); + ensure_approximately_equals("g untouched", mWheel.getRGB().mV[VY], 0.f, 5); + ensure_approximately_equals("b untouched", mWheel.getRGB().mV[VZ], 0.f, 5); + ensure("puck moved off centre", mWheel.getSat() > 0.f); + ensure_approximately_equals("towards red", angleDelta(mWheel.getHue(), 0.f), 0.f, 3); + + mWheel.setChannel(0, 9.f); + ensure_approximately_equals("typed value clamped", mWheel.getRGB().mV[VX], 0.5f, 5); + + mWheel.setChannel(7, 1.f); // out of range, ignored + ensure_approximately_equals("bad index ignored", mWheel.getRGB().mV[VX], 0.5f, 5); + } + + // Reset returns the configured neutral for each flavour. + template<> template<> + void wheel_object::test<16>() + { + asLift(); + mWheel.setPolar(deg(45.f), mWheel.getMaxSat()); + mWheel.reset(); + ensure_approximately_equals("lift neutral is 0", mWheel.getMaster(), 0.f, 5); + ensure_approximately_equals("and colourless", mWheel.getSat(), 0.f, 5); + + asGain(); + mWheel.reset(); + ensure_approximately_equals("gain neutral is 1", mWheel.getMaster(), 1.f, 5); + + asTint(); + mWheel.reset(); + ensure_approximately_equals("tint neutral is 0.5", mWheel.getMaster(), 0.5f, 5); + ensure_approximately_equals("tint neutral r", mWheel.getRGB().mV[VX], 0.5f, 5); + } + + // Re-configuring re-clamps whatever was already held, so switching a wheel + // from lift to gain cannot leave an out-of-range value behind. + template<> template<> + void wheel_object::test<17>() + { + asLift(); + mWheel.setRGB(LLVector3(-0.4f, 0.f, 0.3f)); + asGain(); + for (S32 c = 0; c < 3; ++c) + { + ensure("re-clamped on configure", + mWheel.getRGB().mV[c] >= 0.5f - 1e-5f && mWheel.getRGB().mV[c] <= 1.5f + 1e-5f); + } + } + + // A reversed range is accepted rather than producing a negative width. + template<> template<> + void wheel_object::test<18>() + { + mWheel.configure(0.f, 0.5f, -0.5f, false); + ensure_approximately_equals("min is the low one", mWheel.getMin(), -0.5f, 5); + ensure_approximately_equals("max is the high one", mWheel.getMax(), 0.5f, 5); + ensure("max saturation is positive", mWheel.getMaxSat() > 0.f); + } +} diff --git a/indra/newview/tests/alcurvemodel_test.cpp b/indra/newview/tests/alcurvemodel_test.cpp new file mode 100644 index 0000000000..5a12549edc --- /dev/null +++ b/indra/newview/tests/alcurvemodel_test.cpp @@ -0,0 +1,484 @@ +/** + * @file alcurvemodel_test.cpp + * @brief Unit tests for the curve editor's shape model + * + * Copyright (c) 2026, Alchemy Viewer Project. + * + * The source code in this file is provided to you under the terms of the + * GNU Lesser General Public License, version 2.1, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A + * PARTICULAR PURPOSE. Terms of the LGPL can be found in doc/LGPL-licence.txt + * in this distribution, or online at http://www.gnu.org/licenses/lgpl-2.1.txt + * + */ + +#include "linden_common.h" + +#include "../test/lltut.h" + +#include "../alcurvemodel.h" + +#include +#include + +namespace tut +{ + struct curve_data + { + ALCurveModel mCurve; + + /// cg_sCurve transcribed straight from + /// class1/alchemy/colorGradeUtilF.glsl, with the uCurveInvRange that + /// pipeline.cpp uploads folded in. Written out longhand on purpose: + /// the point of the comparison is that it is an independent + /// transcription of the shader, not a call back into the model. + static F32 shaderCurve(F32 x, F32 toe, F32 shoulder, F32 strength) + { + F32 inv_range = 1.0f / std::max(shoulder - toe, 1e-4f); + F32 t = (x - toe) * inv_range; + t = t < 0.0f ? 0.0f : (t > 1.0f ? 1.0f : t); + F32 s = t * t * (3.0f - 2.0f * t); + F32 k = strength < 0.0f ? 0.0f : (strength > 1.0f ? 1.0f : strength); + return x * (1.0f - k) + s * k; // mix(x, s, k) + } + + /// applySplitToning's three luma masks, transcribed the same way and + /// for the same reason: an independent copy of the shader, so a change + /// to either side shows up as a disagreement rather than as two + /// matching edits. + static void shaderSplitWeights(F32 l, F32 mid, F32& lo, F32& md, F32& hi) + { + auto ss = [](F32 e0, F32 e1, F32 x) { + F32 t = (x - e0) / (e1 - e0); + t = t < 0.0f ? 0.0f : (t > 1.0f ? 1.0f : t); + return t * t * (3.0f - 2.0f * t); + }; + hi = ss(mid, mid + 0.35f, l); + lo = 1.0f - ss(mid - 0.35f, mid, l); + md = std::max(1.0f - hi - lo, 0.0f); + } + + static std::vector pts(std::initializer_list> in) + { + std::vector out; + for (const auto& p : in) + { + out.push_back(ALCurveModel::Point{ p.first, p.second }); + } + return out; + } + }; + + typedef test_group curve_group; + typedef curve_group::object curve_object; + tut::curve_group cg("ALCurveModel"); + + // --- smoothstep: agreement with the shader ------------------------------- + + // Zero strength is the identity, whatever the toe and shoulder say. + template<> template<> + void curve_object::test<1>() + { + mCurve.setSmoothstep(0.2f, 0.8f, 0.f); + for (S32 i = 0; i <= 10; ++i) + { + const F32 x = i * 0.1f; + ensure_approximately_equals("identity at strength 0", mCurve.evaluate(x), x, 6); + } + } + + // Full strength is a pure smoothstep between toe and shoulder. + template<> template<> + void curve_object::test<2>() + { + mCurve.setSmoothstep(0.25f, 0.75f, 1.f); + ensure_approximately_equals("flat below the toe", mCurve.evaluate(0.1f), 0.f, 6); + ensure_approximately_equals("flat above the shoulder", mCurve.evaluate(0.9f), 1.f, 6); + ensure_approximately_equals("midpoint", mCurve.evaluate(0.5f), 0.5f, 6); + } + + // The model and an independent transcription of the shader agree across a + // sweep of parameters, including the degenerate shoulder <= toe. + template<> template<> + void curve_object::test<3>() + { + const F32 toes[] = { 0.f, 0.1f, 0.35f, 0.6f }; + const F32 shoulders[] = { 0.05f, 0.4f, 0.85f, 1.f }; + const F32 strengths[] = { 0.f, 0.25f, 0.7f, 1.f }; + + for (F32 toe : toes) + { + for (F32 shoulder : shoulders) + { + for (F32 strength : strengths) + { + for (S32 i = 0; i <= 20; ++i) + { + const F32 x = i * 0.05f; + ensure_approximately_equals( + "model matches the shader", + ALCurveModel::smoothstep(x, toe, shoulder, strength), + shaderCurve(x, toe, shoulder, strength), 6); + } + } + } + } + } + + // A shoulder at or below the toe must not divide by zero; it degenerates + // to a step at the toe, which is what the shader's guarded reciprocal does. + template<> template<> + void curve_object::test<4>() + { + const F32 y_below = ALCurveModel::smoothstep(0.3f, 0.5f, 0.5f, 1.f); + const F32 y_above = ALCurveModel::smoothstep(0.7f, 0.5f, 0.5f, 1.f); + ensure("finite below", std::isfinite(y_below)); + ensure("finite above", std::isfinite(y_above)); + ensure_approximately_equals("black below the toe", y_below, 0.f, 5); + ensure_approximately_equals("white above the toe", y_above, 1.f, 5); + } + + // Strength outside 0..1 is clamped, matching pipeline.cpp's llclamp on the + // uniform it uploads. + template<> template<> + void curve_object::test<5>() + { + ensure_approximately_equals("over-strength clamps to 1", + ALCurveModel::smoothstep(0.3f, 0.f, 1.f, 4.f), + ALCurveModel::smoothstep(0.3f, 0.f, 1.f, 1.f), 6); + ensure_approximately_equals("negative strength clamps to 0", + ALCurveModel::smoothstep(0.3f, 0.f, 1.f, -2.f), + 0.3f, 6); + } + + // --- spline: ordering and point management ------------------------------- + + // A fresh model is the identity ramp under either kind. + template<> template<> + void curve_object::test<6>() + { + mCurve.setKind(ALCurveModel::KIND_SPLINE); + ensure_equals("two points by default", mCurve.getPointCount(), 2); + for (S32 i = 0; i <= 10; ++i) + { + const F32 x = i * 0.1f; + ensure_approximately_equals("identity ramp", mCurve.evaluate(x), x, 5); + } + } + + // Points added out of order come back sorted by x, and the reported index + // is where the point actually landed. + template<> template<> + void curve_object::test<7>() + { + mCurve.setKind(ALCurveModel::KIND_SPLINE); + const S32 idx_hi = mCurve.addPoint(0.8f, 0.9f); + const S32 idx_lo = mCurve.addPoint(0.2f, 0.1f); + + ensure_equals("high point went before the last", idx_hi, 1); + ensure_equals("low point went before the high one", idx_lo, 1); + ensure_equals("four points", mCurve.getPointCount(), 4); + + const auto& p = mCurve.getPoints(); + for (size_t i = 1; i < p.size(); ++i) + { + ensure("x is ascending", p[i].mX > p[i - 1].mX); + } + ensure_approximately_equals("second point is the low one", p[1].mX, 0.2f, 5); + ensure_approximately_equals("third point is the high one", p[2].mX, 0.8f, 5); + } + + // Coincident points are pushed apart rather than sharing an x, so no + // segment can have zero width. + template<> template<> + void curve_object::test<8>() + { + mCurve.setKind(ALCurveModel::KIND_SPLINE); + mCurve.setPoints(pts({ { 0.f, 0.f }, { 0.5f, 0.3f }, { 0.5f, 0.7f }, { 1.f, 1.f } })); + + const auto& p = mCurve.getPoints(); + ensure_equals("kept all four", (S32)p.size(), 4); + for (size_t i = 1; i < p.size(); ++i) + { + ensure("gap is at least MIN_POINT_GAP", + p[i].mX - p[i - 1].mX >= ALCurveModel::MIN_POINT_GAP - 1e-6f); + } + ensure("evaluation is finite", std::isfinite(mCurve.evaluate(0.5f))); + } + + // A drag cannot push a point past its neighbours, and locked endpoints + // keep their x while still accepting a new height. + template<> template<> + void curve_object::test<9>() + { + mCurve.setKind(ALCurveModel::KIND_SPLINE); + mCurve.setPoints(pts({ { 0.f, 0.f }, { 0.3f, 0.3f }, { 0.6f, 0.6f }, { 1.f, 1.f } })); + + mCurve.movePoint(1, 0.95f, 0.5f); + const auto& p = mCurve.getPoints(); + ensure("clamped below its right neighbour", p[1].mX < p[2].mX); + ensure_approximately_equals("clamped to exactly the gap", + p[1].mX, 0.6f - ALCurveModel::MIN_POINT_GAP, 5); + ensure("still ordered", p[0].mX < p[1].mX && p[2].mX < p[3].mX); + + mCurve.movePoint(0, 0.4f, 0.25f); + ensure_approximately_equals("first point x stays pinned", mCurve.getPoints()[0].mX, 0.f, 6); + ensure_approximately_equals("first point y moved", mCurve.getPoints()[0].mY, 0.25f, 5); + + mCurve.movePoint(3, 0.4f, 0.8f); + ensure_approximately_equals("last point x stays pinned", mCurve.getPoints()[3].mX, 1.f, 6); + } + + // Removal refuses to break the curve: never below two points, and never an + // endpoint while the endpoints are locked. + template<> template<> + void curve_object::test<10>() + { + mCurve.setKind(ALCurveModel::KIND_SPLINE); + mCurve.setPoints(pts({ { 0.f, 0.f }, { 0.5f, 0.4f }, { 1.f, 1.f } })); + + ensure("cannot remove the first", !mCurve.removePoint(0)); + ensure("cannot remove the last", !mCurve.removePoint(2)); + ensure("out of range refused", !mCurve.removePoint(7)); + ensure("interior removed", mCurve.removePoint(1)); + ensure_equals("two left", mCurve.getPointCount(), 2); + ensure("cannot go below two", !mCurve.removePoint(0)); + } + + // --- spline: shape ------------------------------------------------------- + + // The curve passes through every control point. + template<> template<> + void curve_object::test<11>() + { + mCurve.setKind(ALCurveModel::KIND_SPLINE); + mCurve.setPoints(pts({ { 0.f, 0.05f }, { 0.25f, 0.5f }, { 0.7f, 0.6f }, { 1.f, 0.95f } })); + + for (const auto& p : mCurve.getPoints()) + { + ensure_approximately_equals("interpolates its points", + mCurve.evaluate(p.mX), p.mY, 4); + } + } + + // Monotone data yields a monotone curve. A natural or Catmull-Rom spline + // fails this on exactly this shape -- a long flat run into a sharp rise + // makes it dip below the flat before climbing. + template<> template<> + void curve_object::test<12>() + { + mCurve.setKind(ALCurveModel::KIND_SPLINE); + mCurve.setPoints(pts({ { 0.f, 0.f }, { 0.4f, 0.02f }, { 0.6f, 0.05f }, { 0.7f, 0.9f }, { 1.f, 1.f } })); + + std::vector s; + mCurve.sample(s, 257); + for (size_t i = 1; i < s.size(); ++i) + { + ensure("never decreases", s[i] >= s[i - 1] - 1e-5f); + } + } + + // A flat run stays exactly flat -- no ringing between equal-valued points. + template<> template<> + void curve_object::test<13>() + { + mCurve.setKind(ALCurveModel::KIND_SPLINE); + mCurve.setPoints(pts({ { 0.f, 0.f }, { 0.3f, 0.5f }, { 0.7f, 0.5f }, { 1.f, 1.f } })); + + for (S32 i = 0; i <= 8; ++i) + { + const F32 x = 0.3f + i * 0.05f; + ensure_approximately_equals("flat between equal points", mCurve.evaluate(x), 0.5f, 4); + } + } + + // Outside the point range the curve holds its end values rather than + // extrapolating off the graph. + template<> template<> + void curve_object::test<14>() + { + mCurve.setKind(ALCurveModel::KIND_SPLINE); + mCurve.setEndpointsLocked(false); + mCurve.setPoints(pts({ { 0.2f, 0.3f }, { 0.8f, 0.7f } })); + + ensure_approximately_equals("holds the left value", mCurve.evaluate(0.f), 0.3f, 5); + ensure_approximately_equals("holds the right value", mCurve.evaluate(1.f), 0.7f, 5); + } + + // Output never leaves 0..1, so a curve can never ask the caller to write an + // out-of-gamut value. + template<> template<> + void curve_object::test<15>() + { + mCurve.setKind(ALCurveModel::KIND_SPLINE); + mCurve.setPoints(pts({ { 0.f, 0.f }, { 0.05f, 0.99f }, { 0.1f, 0.01f }, { 1.f, 1.f } })); + + std::vector s; + mCurve.sample(s, 129); + for (F32 y : s) + { + ensure("in range", y >= 0.f && y <= 1.f); + ensure("finite", std::isfinite(y)); + } + } + + // --- sampling ------------------------------------------------------------ + + // sample() spans 0..1 inclusive and honours its count. + template<> template<> + void curve_object::test<16>() + { + mCurve.setSmoothstep(0.1f, 0.9f, 1.f); + + std::vector s; + mCurve.sample(s, 65); + ensure_equals("count honoured", (S32)s.size(), 65); + ensure_approximately_equals("starts at evaluate(0)", s.front(), mCurve.evaluate(0.f), 6); + ensure_approximately_equals("ends at evaluate(1)", s.back(), mCurve.evaluate(1.f), 6); + + mCurve.sample(s, 1); + ensure("a single sample is not a curve", s.empty()); + mCurve.sample(s, 0); + ensure("zero samples", s.empty()); + } + + // Unlocking the endpoints lets the first and last points move horizontally; + // re-locking snaps them back to the full domain. + template<> template<> + void curve_object::test<17>() + { + mCurve.setKind(ALCurveModel::KIND_SPLINE); + mCurve.setEndpointsLocked(false); + mCurve.setPoints(pts({ { 0.f, 0.f }, { 0.5f, 0.5f }, { 1.f, 1.f } })); + + mCurve.movePoint(0, 0.2f, 0.1f); + ensure_approximately_equals("unlocked end moved", mCurve.getPoints()[0].mX, 0.2f, 5); + + mCurve.setEndpointsLocked(true); + ensure_approximately_equals("relock snaps to 0", mCurve.getPoints()[0].mX, 0.f, 6); + ensure_approximately_equals("relock snaps to 1", mCurve.getPoints().back().mX, 1.f, 6); + } + + // setPoints refuses to leave the model unusable. + template<> template<> + void curve_object::test<18>() + { + mCurve.setKind(ALCurveModel::KIND_SPLINE); + mCurve.setPoints(pts({ { 0.3f, 0.3f } })); + ensure_equals("degenerate input resets to a ramp", mCurve.getPointCount(), 2); + ensure_approximately_equals("and it is the identity", mCurve.evaluate(0.5f), 0.5f, 5); + } + + // Switching kinds does not disturb the other kind's state, so a widget can + // offer both without a round trip losing anything. + template<> template<> + void curve_object::test<19>() + { + mCurve.setSmoothstep(0.2f, 0.7f, 0.6f); + mCurve.setKind(ALCurveModel::KIND_SPLINE); + mCurve.setPoints(pts({ { 0.f, 0.f }, { 0.5f, 0.8f }, { 1.f, 1.f } })); + + mCurve.setKind(ALCurveModel::KIND_SMOOTHSTEP); + ensure_approximately_equals("toe survived", mCurve.getToe(), 0.2f, 6); + ensure_approximately_equals("shoulder survived", mCurve.getShoulder(), 0.7f, 6); + ensure_approximately_equals("strength survived", mCurve.getStrength(), 0.6f, 6); + + mCurve.setKind(ALCurveModel::KIND_SPLINE); + ensure_equals("points survived", mCurve.getPointCount(), 3); + ensure_approximately_equals("and their values did", + mCurve.evaluate(0.5f), 0.8f, 4); + } + + // --- split-tone bands ---------------------------------------------------- + + // Agreement with the shader, across the whole range of split points the + // Balance slider can ask for. + template<> template<> + void curve_object::test<20>() + { + for (F32 mid : { 0.1f, 0.3f, 0.5f, 0.7f, 0.9f }) + { + for (S32 i = 0; i <= 64; ++i) + { + const F32 l = (F32)i / 64.f; + F32 lo, md, hi; + shaderSplitWeights(l, mid, lo, md, hi); + const auto w = ALCurveModel::splitToneWeights(l, mid); + ensure_approximately_equals("shadow matches the shader", w.mShadow, lo, 5); + ensure_approximately_equals("midtone matches the shader", w.mMidtone, md, 5); + ensure_approximately_equals("highlight matches the shader", w.mHighlight, hi, 5); + } + } + } + + // The three weights partition the luma range: they sum to one everywhere. + // That is what makes the band graph honest -- a tone is never partly + // untinted, only ever shared between neighbouring bands -- and it holds + // because the two ramps meet at the split without overlapping, so the + // midtone remainder is never clamped away. + template<> template<> + void curve_object::test<21>() + { + for (F32 mid : { 0.1f, 0.35f, 0.5f, 0.65f, 0.9f }) + { + for (S32 i = 0; i <= 64; ++i) + { + const auto w = ALCurveModel::splitToneWeights((F32)i / 64.f, mid); + ensure_approximately_equals("weights sum to one", + w.mShadow + w.mMidtone + w.mHighlight, 1.f, 5); + ensure("shadow in range", w.mShadow >= 0.f && w.mShadow <= 1.f); + ensure("midtone in range", w.mMidtone >= 0.f && w.mMidtone <= 1.f); + ensure("highlight in range", w.mHighlight >= 0.f && w.mHighlight <= 1.f); + } + } + + const F32 mid = 0.55f; + const auto at_mid = ALCurveModel::splitToneWeights(mid, mid); + ensure_approximately_equals("midtone peaks at the split", at_mid.mMidtone, 1.f, 5); + ensure_approximately_equals("shadow is spent there", at_mid.mShadow, 0.f, 5); + ensure_approximately_equals("highlight has not started", at_mid.mHighlight, 0.f, 5); + + const auto black = ALCurveModel::splitToneWeights(0.f, mid); + const auto white = ALCurveModel::splitToneWeights(1.f, mid); + ensure_approximately_equals("black is all shadow", black.mShadow, 1.f, 5); + ensure_approximately_equals("white is all highlight", white.mHighlight, 1.f, 5); + + // Monotone, so the bands cannot cross back over themselves. If the + // shader's smoothstep edges were ever swapped, this is what notices. + F32 prev_hi = -1.f, prev_lo = 2.f; + for (S32 i = 0; i <= 64; ++i) + { + const auto w = ALCurveModel::splitToneWeights((F32)i / 64.f, mid); + ensure("highlight never falls", w.mHighlight >= prev_hi - 1e-5f); + ensure("shadow never rises", w.mShadow <= prev_lo + 1e-5f); + prev_hi = w.mHighlight; + prev_lo = w.mShadow; + } + } + + // Balance and split point invert each other across the whole slider range, + // which is what lets the graph's handle be read back into the setting + // without the value creeping a little on every drag. + template<> template<> + void curve_object::test<22>() + { + for (S32 i = -10; i <= 10; ++i) + { + const F32 balance = (F32)i / 10.f; + const F32 mid = ALCurveModel::splitToneMid(balance); + ensure("split point stays in range", mid >= 0.1f - 1e-5f && mid <= 0.9f + 1e-5f); + ensure_approximately_equals("balance round-trips", + ALCurveModel::splitToneBalance(mid), balance, 5); + } + + ensure_approximately_equals("neutral balance splits at mid grey", + ALCurveModel::splitToneMid(0.f), 0.5f, 6); + // Clamped, not wrapped: pipeline.cpp clamps the balance before it + // uploads the split point, so the graph must agree rather than plot a + // split the renderer will never use. + ensure_approximately_equals("out-of-range balance clamps", + ALCurveModel::splitToneMid(3.f), 0.9f, 6); + ensure_approximately_equals("and so does the inverse", + ALCurveModel::splitToneBalance(2.f), 1.f, 6); + } +} diff --git a/indra/newview/tests/aldaycyclelandmarks_test.cpp b/indra/newview/tests/aldaycyclelandmarks_test.cpp new file mode 100644 index 0000000000..28852cb9e4 --- /dev/null +++ b/indra/newview/tests/aldaycyclelandmarks_test.cpp @@ -0,0 +1,163 @@ +/** + * @file aldaycyclelandmarks_test.cpp + * @brief Unit tests for finding landmarks in a day cycle + * + * Copyright (c) 2026, Alchemy Viewer Project. + * + * The source code in this file is provided to you under the terms of the + * GNU Lesser General Public License, version 2.1, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A + * PARTICULAR PURPOSE. Terms of the LGPL can be found in doc/LGPL-licence.txt + * in this distribution, or online at http://www.gnu.org/licenses/lgpl-2.1.txt + * + */ + +#include "linden_common.h" + +#include "../test/lltut.h" + +#include "../aldaycyclelandmarks.h" + +#include + +namespace +{ + // File scope rather than a fixture member: TUT's test bodies reach the + // fixture through a dependent base, so a name inherited from it is not + // visible inside a lambda written in one. + constexpr F32 TWO_PI = 6.28318530718f; +} + +namespace tut +{ + struct daycycle_landmarks + { + /// The ordinary case: one rise and one set per cycle, noon halfway + /// between them. Offsetting the phase moves every landmark with it, + /// which is the whole point of searching rather than assuming. + static ALDayCycleLandmarks::altitude_sampler_t sine(F32 phase = 0.f) + { + return [phase](F32 p) { return std::sin(TWO_PI * (p - phase)); }; + } + + /// How far apart two cycle positions are, the short way round. A + /// landmark at 0.999 and one at 0.001 are neighbours, not opposites. + static F32 apart(F32 a, F32 b) + { + const F32 d = std::fabs(a - b); + return (d > 0.5f) ? (1.f - d) : d; + } + }; + + typedef test_group landmark_group; + typedef landmark_group::object landmark_object; + tut::landmark_group lg("ALDayCycleLandmarks"); + + // A plain sine puts sunrise at 0, noon at a quarter, sunset at a half and + // midnight at three quarters. + template<> template<> + void landmark_object::test<1>() + { + const auto marks = ALDayCycleLandmarks::find(sine()); + + ensure("has sunrise", marks.has_sunrise); + ensure("has noon", marks.has_noon); + ensure("has sunset", marks.has_sunset); + ensure("has midnight", marks.has_midnight); + + ensure("sunrise at 0", apart(marks.sunrise, 0.f) < 0.02f); + ensure("noon at 0.25", apart(marks.noon, 0.25f) < 0.02f); + ensure("sunset at 0.5", apart(marks.sunset, 0.5f) < 0.02f); + ensure("midnight at 0.75", apart(marks.midnight, 0.75f) < 0.02f); + } + + // Every landmark moves with the cycle. This is the case that a hardcoded + // "noon is 0.5" gets wrong, and the reason this code exists. + template<> template<> + void landmark_object::test<2>() + { + const F32 phase = 0.3f; + const auto marks = ALDayCycleLandmarks::find(sine(phase)); + + ensure("sunrise moved", apart(marks.sunrise, phase) < 0.02f); + ensure("noon moved", apart(marks.noon, phase + 0.25f) < 0.02f); + ensure("sunset moved", apart(marks.sunset, std::fmod(phase + 0.5f, 1.f)) < 0.02f); + ensure("midnight moved", apart(marks.midnight, std::fmod(phase + 0.75f, 1.f)) < 0.02f); + } + + // A crossing that falls between two samples is interpolated, so the answer + // is better than the sample grid rather than snapped to it. + template<> template<> + void landmark_object::test<3>() + { + // Sunrise sits at 0.1, which no 16-sample grid position lands on. + const auto marks = ALDayCycleLandmarks::find(sine(0.1f), 16); + const F32 grid = 1.f / 16.f; + + ensure("has sunrise", marks.has_sunrise); + ensure("beats the grid", apart(marks.sunrise, 0.1f) < grid * 0.5f); + } + + // A sun that never sets has a brightest moment and nothing else: naming a + // sunrise there would be inventing one. + template<> template<> + void landmark_object::test<4>() + { + const auto marks = ALDayCycleLandmarks::find( + [](F32 p) { return 0.5f + 0.25f * std::sin(TWO_PI * p); }); + + ensure("has noon", marks.has_noon); + ensure("no sunrise", !marks.has_sunrise); + ensure("no sunset", !marks.has_sunset); + ensure("no midnight", !marks.has_midnight); + ensure("noon at the peak", apart(marks.noon, 0.25f) < 0.02f); + } + + // A sun that never rises is the same argument the other way up. + template<> template<> + void landmark_object::test<5>() + { + const auto marks = ALDayCycleLandmarks::find( + [](F32 p) { return -0.5f + 0.25f * std::sin(TWO_PI * p); }); + + ensure("has midnight", marks.has_midnight); + ensure("no noon", !marks.has_noon); + ensure("no sunrise", !marks.has_sunrise); + ensure("no sunset", !marks.has_sunset); + } + + // A cycle built from one repeated frame -- which is what the viewer's own + // default day cycle actually is -- has no moment to single out. + template<> template<> + void landmark_object::test<6>() + { + const auto marks = ALDayCycleLandmarks::find([](F32) { return 0.5f; }); + + ensure("nothing to find", !marks.any()); + } + + // Landmarks are cycle positions, so they stay inside [0, 1) even when the + // crossing they came from sits across the seam. + template<> template<> + void landmark_object::test<7>() + { + for (S32 i = 0; i < 20; ++i) + { + const F32 phase = (F32)i / 20.f; + const auto marks = ALDayCycleLandmarks::find(sine(phase)); + + ensure("sunrise in range", marks.sunrise >= 0.f && marks.sunrise < 1.f); + ensure("noon in range", marks.noon >= 0.f && marks.noon < 1.f); + ensure("sunset in range", marks.sunset >= 0.f && marks.sunset < 1.f); + ensure("midnight in range", marks.midnight >= 0.f && marks.midnight < 1.f); + } + } + + // Nothing to sample with, nothing to say. + template<> template<> + void landmark_object::test<8>() + { + ensure("no sampler", !ALDayCycleLandmarks::find(nullptr).any()); + ensure("too few samples", !ALDayCycleLandmarks::find(sine(), 1).any()); + } +} diff --git a/indra/newview/tests/algradehistory_test.cpp b/indra/newview/tests/algradehistory_test.cpp new file mode 100644 index 0000000000..7bf9f64f48 --- /dev/null +++ b/indra/newview/tests/algradehistory_test.cpp @@ -0,0 +1,285 @@ +/** + * @file algradehistory_test.cpp + * @brief Unit tests for the Lightbox undo stack + * + * Copyright (c) 2026, Alchemy Viewer Project. + * + * The source code in this file is provided to you under the terms of the + * GNU Lesser General Public License, version 2.1, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A + * PARTICULAR PURPOSE. Terms of the LGPL can be found in doc/LGPL-licence.txt + * in this distribution, or online at http://www.gnu.org/licenses/lgpl-2.1.txt + * + * The behaviour that matters is what counts as "one thing the user did": a + * whole drag, a whole section reset. Get that wrong and Ctrl+Z looks broken + * whichever way it errs. + */ + +#include "linden_common.h" + +#include "../test/lltut.h" + +#include "../algradehistory.h" + +namespace tut +{ + struct history_data + { + ALGradeHistory mHistory; + + /// Simulate a drag: `steps` commits to one control, `dt` apart. + void drag(const std::string& name, S32 steps, F32 dt, F32 start_time = 1.f) + { + for (S32 i = 0; i < steps; ++i) + { + mHistory.record(name, LLSD((F32)i), LLSD((F32)(i + 1)), start_time + dt * (F32)i); + } + } + }; + + typedef test_group history_group; + typedef history_group::object history_object; + tut::history_group hg("ALGradeHistory"); + + // The basic contract: one change, one step, and undo hands back the value + // it started from. + template<> template<> + void history_object::test<1>() + { + ensure("nothing to undo yet", !mHistory.canUndo()); + ensure("nothing to redo yet", !mHistory.canRedo()); + + mHistory.record("RenderColorGradeSaturation", LLSD(1.0), LLSD(1.5), 1.f); + ensure("something to undo", mHistory.canUndo()); + ensure_equals("one step", mHistory.depth(), (size_t)1); + + const auto* t = mHistory.undo(); + ensure("undo returned a transaction", t != nullptr); + ensure_equals("covering one control", t->size(), (size_t)1); + ensure_equals("the right control", t->front().mName, std::string("RenderColorGradeSaturation")); + ensure_equals("restoring the original", t->front().mBefore.asReal(), 1.0); + ensure("nothing left to undo", !mHistory.canUndo()); + ensure("but something to redo", mHistory.canRedo()); + } + + // The one everybody notices. A wheel drag emits a commit per mouse-move; + // without coalescing one drag is a hundred undo steps and Ctrl+Z looks + // broken. The whole drag must collapse to a single step -- and that step + // must start from where the drag started, not from its penultimate value. + template<> template<> + void history_object::test<2>() + { + drag("RenderColorGradeLift", 100, 0.01f); + + ensure_equals("a whole drag is one step", mHistory.depth(), (size_t)1); + + const auto* t = mHistory.undo(); + ensure("got the step", t != nullptr); + ensure_equals("from where the drag began", t->front().mBefore.asReal(), 0.0); + ensure_equals("to where it ended", t->front().mAfter.asReal(), 100.0); + } + + // Pausing ends the gesture: two deliberate edits are two steps, however + // much the same control they touch. + template<> template<> + void history_object::test<3>() + { + mHistory.record("RenderColorGradeGain", LLSD(1.0), LLSD(1.1), 1.f); + mHistory.record("RenderColorGradeGain", LLSD(1.1), LLSD(1.2), + 1.f + ALGradeHistory::COALESCE_SECONDS * 2.f); + ensure_equals("two separate edits", mHistory.depth(), (size_t)2); + } + + // A different control is always a different action, however fast the user + // moved between them -- otherwise nudging saturation would swallow the + // contrast change before it. + template<> template<> + void history_object::test<4>() + { + mHistory.record("RenderColorGradeContrast", LLSD(1.0), LLSD(1.2), 1.f); + mHistory.record("RenderColorGradeSaturation", LLSD(1.0), LLSD(1.2), 1.01f); + ensure_equals("two steps", mHistory.depth(), (size_t)2); + } + + // A section's Reset All writes many controls; that is one thing the user + // did, so it must undo in one go. + template<> template<> + void history_object::test<5>() + { + mHistory.beginGroup(); + mHistory.record("A", LLSD(1.0), LLSD(0.0), 1.f); + mHistory.record("B", LLSD(2.0), LLSD(0.0), 1.f); + mHistory.record("C", LLSD(3.0), LLSD(0.0), 1.f); + mHistory.endGroup(); + + ensure_equals("one step", mHistory.depth(), (size_t)1); + const auto* t = mHistory.undo(); + ensure("got it", t != nullptr); + ensure_equals("covering all three", t->size(), (size_t)3); + } + + // Within a group a control may be written more than once. The group should + // still describe one before and one after for it, or undo would restore an + // intermediate value. + template<> template<> + void history_object::test<6>() + { + mHistory.beginGroup(); + mHistory.record("A", LLSD(1.0), LLSD(2.0), 1.f); + mHistory.record("A", LLSD(2.0), LLSD(3.0), 1.f); + mHistory.endGroup(); + + const auto* t = mHistory.undo(); + ensure("got it", t != nullptr); + ensure_equals("still one control", t->size(), (size_t)1); + ensure_equals("from the original value", t->front().mBefore.asReal(), 1.0); + ensure_equals("to the final one", t->front().mAfter.asReal(), 3.0); + } + + // An edit made after undoing discards the redo tail: that future is no + // longer reachable. + template<> template<> + void history_object::test<7>() + { + mHistory.record("A", LLSD(0.0), LLSD(1.0), 1.f); + mHistory.record("B", LLSD(0.0), LLSD(1.0), 2.f); + mHistory.undo(); + ensure("can redo before the new edit", mHistory.canRedo()); + + mHistory.record("C", LLSD(0.0), LLSD(1.0), 3.f); + ensure("redo tail is gone", !mHistory.canRedo()); + ensure_equals("and the stack was truncated", mHistory.depth(), (size_t)2); + } + + // Redo walks forward again and hands back the destination values. + template<> template<> + void history_object::test<8>() + { + mHistory.record("A", LLSD(0.0), LLSD(1.0), 1.f); + mHistory.undo(); + + const auto* t = mHistory.redo(); + ensure("redo returned the step", t != nullptr); + ensure_equals("with the destination", t->front().mAfter.asReal(), 1.0); + ensure("nothing further to redo", !mHistory.canRedo()); + ensure("and it is undoable again", mHistory.canUndo()); + } + + // Applying an undo writes settings, which is what feeds this class. If that + // fed straight back in it would either loop or corrupt the step being + // undone, so a write immediately after an undo must start a new step rather + // than coalesce into the old one. + template<> template<> + void history_object::test<9>() + { + mHistory.record("A", LLSD(0.0), LLSD(1.0), 1.f); + mHistory.undo(); + mHistory.record("A", LLSD(1.0), LLSD(2.0), 1.01f); + + ensure_equals("the undone step was replaced, not extended", mHistory.depth(), (size_t)1); + const auto* t = mHistory.undo(); + ensure("got the new step", t != nullptr); + ensure_equals("carrying the new values", t->front().mBefore.asReal(), 1.0); + } + + // A long session must not grow without bound, and it is the oldest history + // that stops being interesting. + template<> template<> + void history_object::test<10>() + { + for (size_t i = 0; i < ALGradeHistory::MAX_DEPTH + 20; ++i) + { + mHistory.record("A", LLSD((F64)i), LLSD((F64)(i + 1)), (F32)i * 10.f); + } + ensure_equals("capped", mHistory.depth(), ALGradeHistory::MAX_DEPTH); + + // The newest is still the newest after the drop. + const auto* t = mHistory.undo(); + ensure("got the newest", t != nullptr); + ensure_equals("which is the last one recorded", + t->front().mAfter.asReal(), (F64)(ALGradeHistory::MAX_DEPTH + 20)); + } + + // An empty group leaves nothing behind -- a Reset All on a section that was + // already at defaults should not put a do-nothing step on the stack. + template<> template<> + void history_object::test<11>() + { + mHistory.beginGroup(); + mHistory.endGroup(); + ensure("no step", !mHistory.canUndo()); + ensure_equals("nothing recorded", mHistory.depth(), (size_t)0); + } + + // Nested groups are still one step: applying a Look may reset sections on + // the way through, and the user did one thing. + template<> template<> + void history_object::test<12>() + { + mHistory.beginGroup(); + mHistory.record("A", LLSD(0.0), LLSD(1.0), 1.f); + mHistory.beginGroup(); + mHistory.record("B", LLSD(0.0), LLSD(1.0), 1.f); + mHistory.endGroup(); + mHistory.record("C", LLSD(0.0), LLSD(1.0), 1.f); + mHistory.endGroup(); + + ensure_equals("one step", mHistory.depth(), (size_t)1); + const auto* t = mHistory.undo(); + ensure("got it", t != nullptr); + ensure_equals("covering all three", t->size(), (size_t)3); + } + + // Undoing past the beginning, or redoing past the end, answers null rather + // than misbehaving -- the key handler calls these without checking first. + template<> template<> + void history_object::test<13>() + { + ensure("undo on empty is null", mHistory.undo() == nullptr); + ensure("redo on empty is null", mHistory.redo() == nullptr); + + mHistory.record("A", LLSD(0.0), LLSD(1.0), 1.f); + ensure("undo works once", mHistory.undo() != nullptr); + ensure("and then stops", mHistory.undo() == nullptr); + ensure("redo works once", mHistory.redo() != nullptr); + ensure("and then stops", mHistory.redo() == nullptr); + + mHistory.clear(); + ensure("clear empties it", !mHistory.canUndo() && !mHistory.canRedo()); + ensure_equals("really empty", mHistory.depth(), (size_t)0); + } + + // The cap holds for grouped steps too. Groups cannot evict as they push -- + // that would shift the index the group is accumulating into -- so the + // eviction happens when the group closes, and a session of nothing but + // Look applies and section resets must stay bounded like any other. + template<> template<> + void history_object::test<14>() + { + for (size_t i = 0; i < ALGradeHistory::MAX_DEPTH + 20; ++i) + { + mHistory.beginGroup(); + mHistory.record("A", LLSD((F64)i), LLSD((F64)(i + 1)), 1.f); + mHistory.record("B", LLSD((F64)i), LLSD((F64)(i + 1)), 1.f); + mHistory.endGroup(); + } + ensure_equals("capped", mHistory.depth(), ALGradeHistory::MAX_DEPTH); + ensure_equals("everything on the stack is applied", mHistory.cursor(), mHistory.depth()); + + // The newest is still the newest after the drop, and still whole. + const auto* t = mHistory.undo(); + ensure("got the newest", t != nullptr); + ensure_equals("still covering both controls", t->size(), (size_t)2); + ensure_equals("which is the last group recorded", + t->front().mAfter.asReal(), (F64)(ALGradeHistory::MAX_DEPTH + 20)); + + // And the walk back stops exactly at the cap, with no phantom steps + // left over from the evictions. + size_t undone = 1; + while (mHistory.undo() != nullptr) + { + ++undone; + } + ensure_equals("the whole stack is walkable", undone, ALGradeHistory::MAX_DEPTH); + } +} diff --git a/indra/newview/tests/alscopedata_test.cpp b/indra/newview/tests/alscopedata_test.cpp new file mode 100644 index 0000000000..53d5d9c174 --- /dev/null +++ b/indra/newview/tests/alscopedata_test.cpp @@ -0,0 +1,647 @@ +/** + * @file alscopedata_test.cpp + * @brief Unit tests for the scopes floater's histogram data + * + * Copyright (c) 2026, Alchemy Viewer Project. + * + * The source code in this file is provided to you under the terms of the + * GNU Lesser General Public License, version 2.1, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A + * PARTICULAR PURPOSE. Terms of the LGPL can be found in doc/LGPL-licence.txt + * in this distribution, or online at http://www.gnu.org/licenses/lgpl-2.1.txt + * + */ + +#include "linden_common.h" + +#include "../test/lltut.h" + +#include "../alscopedata.h" + +#include + +namespace tut +{ + struct scope_data + { + ALScopeData mScope; + + /// A buffer of `count` identical RGBA pixels. + static std::vector flat(U8 r, U8 g, U8 b, S32 count) + { + std::vector px; + px.reserve(count * 4); + for (S32 i = 0; i < count; ++i) + { + px.push_back(r); + px.push_back(g); + px.push_back(b); + px.push_back(255); + } + return px; + } + + /// A ramp: pixel i is grey level i, so every bin gets exactly one hit. + static std::vector ramp() + { + std::vector px; + px.reserve(ALScopeData::BIN_COUNT * 4); + for (S32 i = 0; i < ALScopeData::BIN_COUNT; ++i) + { + const U8 v = (U8)i; + px.push_back(v); + px.push_back(v); + px.push_back(v); + px.push_back(255); + } + return px; + } + + static F32 chromaSum(const ALScopeData& d) + { + F32 total = 0.f; + for (S32 u = 0; u < ALScopeData::CHROMA_SIZE; ++u) + { + for (S32 v = 0; v < ALScopeData::CHROMA_SIZE; ++v) + { + total += d.getChromaCell(u, v); + } + } + return total; + } + + /// The one cell holding everything, for a sample of identical pixels. + static void soleCell(const ALScopeData& d, S32& u_out, S32& v_out) + { + u_out = -1; + v_out = -1; + for (S32 u = 0; u < ALScopeData::CHROMA_SIZE; ++u) + { + for (S32 v = 0; v < ALScopeData::CHROMA_SIZE; ++v) + { + if (d.getChromaCell(u, v) > 0.f) + { + u_out = u; + v_out = v; + return; + } + } + } + } + + static F32 binSum(const ALScopeData& d, ALScopeData::EChannel ch) + { + F32 total = 0.f; + for (S32 i = 0; i < ALScopeData::BIN_COUNT; ++i) + { + total += d.getBin(ch, i); + } + return total; + } + }; + + typedef test_group scope_group; + typedef scope_group::object scope_object; + tut::scope_group sg("ALScopeData"); + + // A fresh object measures nothing and reports nothing. + template<> template<> + void scope_object::test<1>() + { + ensure("starts empty", mScope.isEmpty()); + ensure_equals("no samples", mScope.getSampleCount(), 0); + ensure_approximately_equals("no peak", mScope.getPeak(ALScopeData::CH_LUMA), 0.f, 6); + ensure_approximately_equals("no bins", binSum(mScope, ALScopeData::CH_RED), 0.f, 6); + } + + // A flat image puts everything in one bin per channel. + template<> template<> + void scope_object::test<2>() + { + const std::vector px = flat(64, 128, 192, 100); + mScope.accumulate(px.data(), 100, 1); + + ensure_equals("sample count", mScope.getSampleCount(), 100); + ensure_approximately_equals("all red in bin 64", mScope.getBin(ALScopeData::CH_RED, 64), 1.f, 5); + ensure_approximately_equals("all green in bin 128", mScope.getBin(ALScopeData::CH_GREEN, 128), 1.f, 5); + ensure_approximately_equals("all blue in bin 192", mScope.getBin(ALScopeData::CH_BLUE, 192), 1.f, 5); + ensure_approximately_equals("nothing anywhere else", mScope.getBin(ALScopeData::CH_RED, 65), 0.f, 6); + } + + // Bins are shares of the sample, so they sum to one whatever the size. + template<> template<> + void scope_object::test<3>() + { + const std::vector px = ramp(); + mScope.accumulate(px.data(), ALScopeData::BIN_COUNT, 1); + + for (S32 c = 0; c < ALScopeData::CH_COUNT; ++c) + { + ensure_approximately_equals("bins sum to 1", + binSum(mScope, (ALScopeData::EChannel)c), 1.f, 4); + } + ensure_approximately_equals("a flat ramp peaks at 1/256", + mScope.getPeak(ALScopeData::CH_RED), + 1.f / (F32)ALScopeData::BIN_COUNT, 5); + } + + // Two samples of different sizes but the same content compare equal, + // which is the property that lets the display ignore the sample size. + template<> template<> + void scope_object::test<4>() + { + ALScopeData small_sample; + ALScopeData large_sample; + const std::vector a = flat(200, 100, 50, 9); + const std::vector b = flat(200, 100, 50, 9000); + small_sample.accumulate(a.data(), 9, 1); + large_sample.accumulate(b.data(), 9000, 1); + + for (S32 i = 0; i < ALScopeData::BIN_COUNT; ++i) + { + ensure_approximately_equals("size independent", + small_sample.getBin(ALScopeData::CH_RED, i), + large_sample.getBin(ALScopeData::CH_RED, i), 5); + } + } + + // Luma uses Rec.709 on the encoded values. Pure green must land far above + // pure blue; equal grey must land on itself. + template<> template<> + void scope_object::test<5>() + { + ALScopeData green; + ALScopeData blue; + ALScopeData grey; + const std::vector g = flat(0, 255, 0, 16); + const std::vector b = flat(0, 0, 255, 16); + const std::vector n = flat(137, 137, 137, 16); + green.accumulate(g.data(), 16, 1); + blue.accumulate(b.data(), 16, 1); + grey.accumulate(n.data(), 16, 1); + + // 0.7152 * 255 = 182.4; 0.0722 * 255 = 18.4 + ensure_approximately_equals("green luma", green.getBin(ALScopeData::CH_LUMA, 182), 1.f, 5); + ensure_approximately_equals("blue luma", blue.getBin(ALScopeData::CH_LUMA, 18), 1.f, 5); + ensure_approximately_equals("grey is its own luma", grey.getBin(ALScopeData::CH_LUMA, 137), 1.f, 5); + } + + // The weights sum to unity in fixed point, so white cannot overflow the + // last bin and black cannot underflow the first. + template<> template<> + void scope_object::test<6>() + { + ALScopeData white; + ALScopeData black; + const std::vector w = flat(255, 255, 255, 4); + const std::vector k = flat(0, 0, 0, 4); + white.accumulate(w.data(), 4, 1); + black.accumulate(k.data(), 4, 1); + + ensure_approximately_equals("white luma is 255", + white.getBin(ALScopeData::CH_LUMA, ALScopeData::BIN_COUNT - 1), 1.f, 5); + ensure_approximately_equals("black luma is 0", + black.getBin(ALScopeData::CH_LUMA, 0), 1.f, 5); + } + + // Clipping readouts are the extreme bins, reported as a share of sample. + template<> template<> + void scope_object::test<7>() + { + std::vector px; + // 10 blown, 30 crushed, 60 mid. + for (S32 i = 0; i < 10; ++i) { px.insert(px.end(), { 255, 255, 255, 255 }); } + for (S32 i = 0; i < 30; ++i) { px.insert(px.end(), { 0, 0, 0, 255 }); } + for (S32 i = 0; i < 60; ++i) { px.insert(px.end(), { 128, 128, 128, 255 }); } + mScope.accumulate(px.data(), 100, 1); + + ensure_approximately_equals("10% blown", + mScope.getClippedHigh(ALScopeData::CH_RED), 0.10f, 4); + ensure_approximately_equals("30% crushed", + mScope.getClippedLow(ALScopeData::CH_RED), 0.30f, 4); + ensure_approximately_equals("luma agrees on the blown share", + mScope.getClippedHigh(ALScopeData::CH_LUMA), 0.10f, 4); + } + + // A single blown pixel in a large sample still registers. This is the + // case a box-filtered downsample would have averaged away, and the reason + // the capture point-samples. + template<> template<> + void scope_object::test<8>() + { + std::vector px = flat(100, 100, 100, 10000); + px[0] = px[1] = px[2] = 255; + mScope.accumulate(px.data(), 10000, 1); + + ensure("one blown pixel is visible", mScope.getClippedHigh(ALScopeData::CH_RED) > 0.f); + ensure_approximately_equals("and it is 1 in 10000", + mScope.getClippedHigh(ALScopeData::CH_RED), 0.0001f, 6); + } + + // Accumulating replaces rather than adding to the previous measurement. + template<> template<> + void scope_object::test<9>() + { + const std::vector a = flat(10, 10, 10, 50); + const std::vector b = flat(200, 200, 200, 50); + mScope.accumulate(a.data(), 50, 1); + mScope.accumulate(b.data(), 50, 1); + + ensure_approximately_equals("old bin cleared", mScope.getBin(ALScopeData::CH_RED, 10), 0.f, 6); + ensure_approximately_equals("new bin full", mScope.getBin(ALScopeData::CH_RED, 200), 1.f, 5); + ensure_equals("count is the latest", mScope.getSampleCount(), 50); + } + + // Degenerate input clears rather than reading past the buffer. + template<> template<> + void scope_object::test<10>() + { + const std::vector px = flat(60, 60, 60, 8); + mScope.accumulate(px.data(), 8, 1); + ensure("measured", !mScope.isEmpty()); + + mScope.accumulate(px.data(), 0, 1); + ensure("zero count clears", mScope.isEmpty()); + + mScope.accumulate(px.data(), 8, 1); + mScope.accumulate(nullptr, 8, 1); + ensure("null clears", mScope.isEmpty()); + + mScope.accumulate(px.data(), 8, 1); + mScope.accumulate(px.data(), -5, 1); + ensure("negative count clears", mScope.isEmpty()); + } + + // The first blend takes the new sample whole; fading up from zero would + // read as the scope being broken for the first few updates. + template<> template<> + void scope_object::test<11>() + { + ALScopeData fresh; + const std::vector px = flat(90, 90, 90, 20); + fresh.accumulate(px.data(), 20, 1); + + mScope.blendToward(fresh, 0.25f); + ensure_approximately_equals("taken whole", mScope.getBin(ALScopeData::CH_RED, 90), 1.f, 5); + ensure_equals("count carried", mScope.getSampleCount(), 20); + } + + // A later blend moves partway and converges on repetition. + template<> template<> + void scope_object::test<12>() + { + ALScopeData first; + ALScopeData second; + const std::vector a = flat(40, 40, 40, 20); + const std::vector b = flat(200, 200, 200, 20); + first.accumulate(a.data(), 20, 1); + second.accumulate(b.data(), 20, 1); + + mScope.blendToward(first, 1.f); + mScope.blendToward(second, 0.5f); + ensure_approximately_equals("halfway out of the old bin", + mScope.getBin(ALScopeData::CH_RED, 40), 0.5f, 4); + ensure_approximately_equals("halfway into the new one", + mScope.getBin(ALScopeData::CH_RED, 200), 0.5f, 4); + + for (S32 i = 0; i < 40; ++i) + { + mScope.blendToward(second, 0.5f); + } + ensure_approximately_equals("converges on the new sample", + mScope.getBin(ALScopeData::CH_RED, 200), 1.f, 4); + ensure_approximately_equals("and leaves the old", + mScope.getBin(ALScopeData::CH_RED, 40), 0.f, 4); + } + + // Blending never leaves the peak below a bin it will be asked to scale. + template<> template<> + void scope_object::test<13>() + { + ALScopeData first; + ALScopeData second; + const std::vector a = ramp(); + const std::vector b = flat(77, 77, 77, ALScopeData::BIN_COUNT); + first.accumulate(a.data(), ALScopeData::BIN_COUNT, 1); + second.accumulate(b.data(), ALScopeData::BIN_COUNT, 1); + + mScope.blendToward(first, 1.f); + mScope.blendToward(second, 0.3f); + + for (S32 c = 0; c < ALScopeData::CH_COUNT; ++c) + { + const ALScopeData::EChannel ch = (ALScopeData::EChannel)c; + const F32 peak = mScope.getPeak(ch); + for (S32 i = 0; i < ALScopeData::BIN_COUNT; ++i) + { + ensure("no bin exceeds the peak", mScope.getBin(ch, i) <= peak + 1e-6f); + } + } + } + + // Blending toward nothing keeps what is already measured, so closing and + // reopening the source does not wipe the display. + template<> template<> + void scope_object::test<14>() + { + const std::vector px = flat(150, 150, 150, 32); + mScope.accumulate(px.data(), 32, 1); + + const ALScopeData nothing; + mScope.blendToward(nothing, 0.5f); + ensure_approximately_equals("unchanged", mScope.getBin(ALScopeData::CH_RED, 150), 1.f, 5); + } + + // Out-of-range queries are answered, not asserted on. + template<> template<> + void scope_object::test<15>() + { + const std::vector px = flat(1, 2, 3, 4); + mScope.accumulate(px.data(), 4, 1); + + ensure_approximately_equals("negative bin", mScope.getBin(ALScopeData::CH_RED, -1), 0.f, 6); + ensure_approximately_equals("past the end", + mScope.getBin(ALScopeData::CH_RED, ALScopeData::BIN_COUNT), 0.f, 6); + ensure_approximately_equals("bad channel", + mScope.getBin((ALScopeData::EChannel)99, 0), 0.f, 6); + ensure_approximately_equals("bad channel peak", + mScope.getPeak((ALScopeData::EChannel)-3), 0.f, 6); + } + // --- vectorscope --------------------------------------------------------- + + // Grey has no chroma, so every neutral sample lands in the middle. That is + // the reading a colourist checks first: a trace off-centre at the origin + // means a cast. + template<> template<> + void scope_object::test<16>() + { + for (U8 level : { (U8)0, (U8)64, (U8)128, (U8)200, (U8)255 }) + { + mScope.accumulate(flat(level, level, level, 16).data(), 16, 1); + + S32 u = -1, v = -1; + soleCell(mScope, u, v); + // Two central cells straddle zero on an even grid; either is right. + ensure("grey sits at the centre in u", u == ALScopeData::CHROMA_SIZE / 2 || + u == ALScopeData::CHROMA_SIZE / 2 - 1); + ensure("grey sits at the centre in v", v == ALScopeData::CHROMA_SIZE / 2 || + v == ALScopeData::CHROMA_SIZE / 2 - 1); + } + } + + // The primaries land in the directions the wheels put them, so pushing a + // wheel and watching the trace agree is meaningful. Red at angle 0 means + // right of centre; green and blue at 120 and 240 degrees. + template<> template<> + void scope_object::test<17>() + { + const S32 mid = ALScopeData::CHROMA_SIZE / 2; + + mScope.accumulate(flat(255, 0, 0, 8).data(), 8, 1); + S32 u = -1, v = -1; + soleCell(mScope, u, v); + ensure("red is right of centre", u > mid); + ensure("and level with it", v == mid || v == mid - 1); + + mScope.accumulate(flat(0, 255, 0, 8).data(), 8, 1); + soleCell(mScope, u, v); + ensure("green is left of centre", u < mid); + ensure("and above it", v > mid); + + mScope.accumulate(flat(0, 0, 255, 8).data(), 8, 1); + soleCell(mScope, u, v); + ensure("blue is left of centre", u < mid); + ensure("and below it", v < mid); + } + + // Cells are shares of the sample, like the histogram's bins, so they sum + // to one and blend the same way. + template<> template<> + void scope_object::test<18>() + { + mScope.accumulate(ramp().data(), ALScopeData::BIN_COUNT, 1); + ensure_approximately_equals("cells sum to one", chromaSum(mScope), 1.f, 4); + ensure("peak is a real share", mScope.getChromaPeak() > 0.f && + mScope.getChromaPeak() <= 1.f); + + // A ramp is entirely neutral, so all of it is in one place. + ensure_approximately_equals("a grey ramp is a point", mScope.getChromaPeak(), 1.f, 4); + + ALScopeData red; + red.accumulate(flat(255, 0, 0, 8).data(), 8, 1); + mScope.blendToward(red, 0.5f); + ensure_approximately_equals("still sums to one after a blend", + chromaSum(mScope), 1.f, 4); + ensure_approximately_equals("and the peak matches the cells", + mScope.getChromaPeak(), 0.5f, 4); + } + + // Out-of-range cells answer zero rather than assert, and clear() empties + // the grid along with everything else. + template<> template<> + void scope_object::test<19>() + { + mScope.accumulate(flat(200, 30, 30, 4).data(), 4, 1); + ensure("something was measured", mScope.getChromaPeak() > 0.f); + + ensure_approximately_equals("negative u", mScope.getChromaCell(-1, 0), 0.f, 6); + ensure_approximately_equals("negative v", mScope.getChromaCell(0, -1), 0.f, 6); + ensure_approximately_equals("past the end in u", + mScope.getChromaCell(ALScopeData::CHROMA_SIZE, 0), 0.f, 6); + ensure_approximately_equals("past the end in v", + mScope.getChromaCell(0, ALScopeData::CHROMA_SIZE), 0.f, 6); + + mScope.clear(); + ensure_approximately_equals("cleared peak", mScope.getChromaPeak(), 0.f, 6); + ensure_approximately_equals("cleared cells", chromaSum(mScope), 0.f, 6); + } + + // Cell centres run -1 to 1 in units of MAX_CHROMA, and the binning is the + // inverse of that mapping -- so a plot laid out from chromaCellCentre puts + // the trace where accumulate() put it, not half a cell away. + template<> template<> + void scope_object::test<20>() + { + const S32 last = ALScopeData::CHROMA_SIZE - 1; + ensure("first cell is at the low edge", ALScopeData::chromaCellCentre(0) < -0.9f); + ensure("last cell is at the high edge", ALScopeData::chromaCellCentre(last) > 0.9f); + ensure_approximately_equals("the grid straddles zero", + ALScopeData::chromaCellCentre(ALScopeData::CHROMA_SIZE / 2) + + ALScopeData::chromaCellCentre(ALScopeData::CHROMA_SIZE / 2 - 1), + 0.f, 5); + for (S32 i = 1; i < ALScopeData::CHROMA_SIZE; ++i) + { + ensure("centres increase", + ALScopeData::chromaCellCentre(i) > ALScopeData::chromaCellCentre(i - 1)); + } + } + + // The whole reason the waveform exists: a histogram cannot tell a blown sky + // from a blown face, and this must. Left half black, right half white -- + // they have to land in different columns, at opposite ends of the scale. + template<> template<> + void scope_object::test<21>() + { + const S32 W = 64, H = 8; + std::vector px((size_t)W * H * 4, 255); + for (S32 y = 0; y < H; ++y) + { + for (S32 x = 0; x < W; ++x) + { + const U8 v = (x < W / 2) ? 0 : 255; + const size_t i = ((size_t)y * W + x) * 4; + px[i + 0] = px[i + 1] = px[i + 2] = v; + px[i + 3] = 255; + } + } + mScope.accumulate(px.data(), W, H); + + const S32 top = ALScopeData::WAVE_LEVELS - 1; + const S32 left = ALScopeData::WAVE_COLUMNS / 4; // inside the black half + const S32 right = 3 * ALScopeData::WAVE_COLUMNS / 4; // inside the white half + + ensure_approximately_equals("black column is entirely at the bottom", + mScope.getWaveCell(ALScopeData::CH_LUMA, left, 0), 1.f, 5); + ensure_equals("black column has nothing at the top", + mScope.getWaveCell(ALScopeData::CH_LUMA, left, top), 0.f); + ensure_approximately_equals("white column is entirely at the top", + mScope.getWaveCell(ALScopeData::CH_LUMA, right, top), 1.f, 5); + ensure_equals("white column has nothing at the bottom", + mScope.getWaveCell(ALScopeData::CH_LUMA, right, 0), 0.f); + } + + // A left-to-right ramp is the shape everyone recognises: the trace climbs + // across the frame. If columns and levels were ever transposed, or the + // column mapping inverted, this is what catches it. + template<> template<> + void scope_object::test<22>() + { + const S32 W = ALScopeData::WAVE_COLUMNS, H = 4; + std::vector px((size_t)W * H * 4, 255); + for (S32 y = 0; y < H; ++y) + { + for (S32 x = 0; x < W; ++x) + { + const U8 v = (U8)(x * 255 / (W - 1)); + const size_t i = ((size_t)y * W + x) * 4; + px[i + 0] = px[i + 1] = px[i + 2] = v; + px[i + 3] = 255; + } + } + mScope.accumulate(px.data(), W, H); + + // One value per column, so exactly one level per column is lit, and it + // must rise with the column. + S32 previous = -1; + for (S32 column = 0; column < ALScopeData::WAVE_COLUMNS; ++column) + { + S32 lit = -1; + for (S32 level = 0; level < ALScopeData::WAVE_LEVELS; ++level) + { + if (mScope.getWaveCell(ALScopeData::CH_LUMA, column, level) > 0.f) + { + ensure_equals("only one level per column", lit, -1); + lit = level; + } + } + ensure("every column is lit somewhere", lit >= 0); + ensure("the trace never descends", lit >= previous); + previous = lit; + } + ensure("and it does climb", previous > 0); + } + + // Shares are per column, not per sample -- that is what lets the plot's + // intensity mean the same thing whatever the sample's width was. So a flat + // frame reads 1.0 in every column, not 1/WAVE_COLUMNS. + template<> template<> + void scope_object::test<23>() + { + auto column_total = [&](S32 column) + { + F32 total = 0.f; + for (S32 level = 0; level < ALScopeData::WAVE_LEVELS; ++level) + { + total += mScope.getWaveCell(ALScopeData::CH_LUMA, column, level); + } + return total; + }; + + // Wider than the grid, which is the real case -- the sample is ~320px + // against 128 columns. Each column takes an unequal share of the source + // columns, so this only reads 1.0 if each is divided by its own count + // and not by the sample's height. + const S32 W = 2 * ALScopeData::WAVE_COLUMNS + 7, H = 5; + mScope.accumulate(flat(128, 128, 128, W * H).data(), W, H); + for (S32 column = 0; column < ALScopeData::WAVE_COLUMNS; ++column) + { + ensure_approximately_equals("every column holds one column's worth", + column_total(column), 1.f, 5); + } + ensure_approximately_equals("peak is a full column", + mScope.getWavePeak(ALScopeData::CH_LUMA), 1.f, 5); + + // Narrower than the grid: some columns have no pixels behind them at + // all. Those stay empty rather than dividing by zero, and the ones that + // do have pixels still read a full column. + const S32 NARROW = 40; + mScope.accumulate(flat(128, 128, 128, NARROW * H).data(), NARROW, H); + S32 filled = 0; + for (S32 column = 0; column < ALScopeData::WAVE_COLUMNS; ++column) + { + const F32 total = column_total(column); + if (total > 0.f) + { + ++filled; + ensure_approximately_equals("a filled column is still whole", total, 1.f, 5); + } + } + ensure_equals("one filled column per source column", filled, NARROW); + } + + // Channels are measured separately, which is the whole point of a parade: a + // cast shows as the three traces sitting at different heights. + template<> template<> + void scope_object::test<24>() + { + const S32 W = 32, H = 4; + mScope.accumulate(flat(255, 128, 0, W * H).data(), W, H); + + const S32 column = ALScopeData::WAVE_COLUMNS / 2; + const S32 top = ALScopeData::WAVE_LEVELS - 1; + + ensure_approximately_equals("red is at the top", + mScope.getWaveCell(ALScopeData::CH_RED, column, top), 1.f, 5); + ensure_approximately_equals("blue is at the bottom", + mScope.getWaveCell(ALScopeData::CH_BLUE, column, 0), 1.f, 5); + ensure_equals("red is not also at the bottom", + mScope.getWaveCell(ALScopeData::CH_RED, column, 0), 0.f); + ensure("green is at neither end", + mScope.getWaveCell(ALScopeData::CH_GREEN, column, 0) == 0.f && + mScope.getWaveCell(ALScopeData::CH_GREEN, column, top) == 0.f); + } + + // A plot walks the whole grid without checking, so out-of-range reads and a + // scope that has never measured anything both have to answer zero rather + // than index a vector that is deliberately empty until first use. + template<> template<> + void scope_object::test<25>() + { + ALScopeData fresh; + ensure_equals("unmeasured reads zero", fresh.getWaveCell(ALScopeData::CH_LUMA, 0, 0), 0.f); + ensure_equals("unmeasured has no peak", fresh.getWavePeak(ALScopeData::CH_LUMA), 0.f); + + mScope.accumulate(flat(200, 200, 200, 16).data(), 16, 1); + ensure_equals("negative column", mScope.getWaveCell(ALScopeData::CH_LUMA, -1, 0), 0.f); + ensure_equals("column past the end", + mScope.getWaveCell(ALScopeData::CH_LUMA, ALScopeData::WAVE_COLUMNS, 0), 0.f); + ensure_equals("level past the end", + mScope.getWaveCell(ALScopeData::CH_LUMA, 0, ALScopeData::WAVE_LEVELS), 0.f); + ensure_equals("bad channel", mScope.getWaveCell(ALScopeData::CH_COUNT, 0, 0), 0.f); + + // And clearing has to let go of the grid again. + mScope.clear(); + ensure_equals("cleared reads zero", mScope.getWaveCell(ALScopeData::CH_LUMA, 0, 0), 0.f); + ensure_equals("cleared has no peak", mScope.getWavePeak(ALScopeData::CH_LUMA), 0.f); + } +} diff --git a/indra/newview/tests/alwhitebalancesolver_test.cpp b/indra/newview/tests/alwhitebalancesolver_test.cpp new file mode 100644 index 0000000000..9b062e967d --- /dev/null +++ b/indra/newview/tests/alwhitebalancesolver_test.cpp @@ -0,0 +1,229 @@ +/** + * @file alwhitebalancesolver_test.cpp + * @brief Unit tests for the white-balance forward map and its inverse + * + * Copyright (c) 2026, Alchemy Viewer Project. + * + * The source code in this file is provided to you under the terms of the + * GNU Lesser General Public License, version 2.1, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A + * PARTICULAR PURPOSE. Terms of the LGPL can be found in doc/LGPL-licence.txt + * in this distribution, or online at http://www.gnu.org/licenses/lgpl-2.1.txt + * + */ + +#include "linden_common.h" + +#include "../test/lltut.h" + +#include "../alwhitebalancesolver.h" + +#include + +namespace tut +{ + struct wb_data + { + /// How far apart two gains are, as the solver measures it: RMS of the + /// log ratios of the two free channels. Green is pinned to 1 on both + /// sides by construction, so it carries no information. + static F32 gainDistance(const LLVector3& a, const LLVector3& b) + { + const F32 dr = std::log(std::max(a.mV[VX], 1e-6f)) - std::log(std::max(b.mV[VX], 1e-6f)); + const F32 db = std::log(std::max(a.mV[VZ], 1e-6f)) - std::log(std::max(b.mV[VZ], 1e-6f)); + return std::sqrt((dr * dr + db * db) * 0.5f); + } + }; + + typedef test_group wb_group; + typedef wb_group::object wb_object; + tut::wb_group wbg("ALWhiteBalanceSolver"); + + // Neutral in, neutral out. The shader's identity fast path tests the gain + // against exactly vec3(1), so anything else here would leave the default + // settings paying for a colour transform that does nothing. + template<> template<> + void wb_object::test<1>() + { + const LLVector3 g = ALWhiteBalanceSolver::gain(0.f, 0.f); + ensure_approximately_equals("neutral red", g.mV[VX], 1.f, 6); + ensure_approximately_equals("neutral green", g.mV[VY], 1.f, 6); + ensure_approximately_equals("neutral blue", g.mV[VZ], 1.f, 6); + } + + // Green is pinned across the whole range, which is what makes temperature + // a colour control rather than an exposure one. + template<> template<> + void wb_object::test<2>() + { + for (S32 i = -5; i <= 5; ++i) + { + for (S32 j = -2; j <= 2; ++j) + { + const LLVector3 g = ALWhiteBalanceSolver::gain(i * 1000.f, j * 0.5f); + ensure_approximately_equals("green stays pinned", g.mV[VY], 1.f, 6); + } + } + } + + // The direction of the temperature control. A negative offset asks for a + // warmer scene, which the renderer delivers by pushing red up relative to + // blue -- if this ever inverts, every tooltip in the Basic panel is wrong. + template<> template<> + void wb_object::test<3>() + { + const LLVector3 warm = ALWhiteBalanceSolver::gain(-2500.f, 0.f); + const LLVector3 cool = ALWhiteBalanceSolver::gain( 2500.f, 0.f); + ensure("warm lifts red above blue", warm.mV[VX] > warm.mV[VZ]); + ensure("cool lifts blue above red", cool.mV[VZ] > cool.mV[VX]); + } + + // The round trip the eyedropper depends on: every usable pair is recovered + // from the gain it produces. + template<> template<> + void wb_object::test<4>() + { + S32 tested = 0; + for (S32 i = -4; i <= 4; ++i) + { + for (S32 j = -4; j <= 4; ++j) + { + const F32 cct = i * 1200.f; + const F32 duv = j * 0.25f; + + // Out of gamut is out of scope: down there the blue gain is + // negative, so every candidate looks alike to the solver and + // no inverse exists to test. Test <9> covers that region. + if (!ALWhiteBalanceSolver::isUsable(cct, duv)) + { + continue; + } + ++tested; + + const LLVector3 want = ALWhiteBalanceSolver::gain(cct, duv); + const ALWhiteBalanceSolver::Result got = ALWhiteBalanceSolver::solve(want); + + // Compared on the gain, not on the parameters. The map is not + // equally sensitive everywhere -- a thousand Kelvin at the + // warm end moves the gain far less than at the cool end -- so + // a tolerance in Kelvin would be either slack where it matters + // or unachievable where it does not. What has to round-trip is + // the colour the renderer will actually apply. + const LLVector3 back = ALWhiteBalanceSolver::gain(got.mCCTOffset, got.mDuv); + ensure("gain round-trips", gainDistance(want, back) < 1e-3f); + ensure("and the solver knows it did", got.mResidual < 1e-3f); + } + } + // Guards against the skip above quietly emptying the sweep. + ensure("the sweep covered most of the range", tested > 60); + } + + // A solution never escapes the sliders' range, whatever it is asked for. + // Out here the answer is a best effort against the edge of the box, and + // the residual is how the caller finds out. + template<> template<> + void wb_object::test<5>() + { + // Both free channels pulled well below green: "make it much greener + // than any light source is". Temperature trades red against blue and + // tint moves them together but only so far, so this lies off the + // reachable surface entirely -- unlike, say, (6, 1, 0.05), which looks + // extreme and turns out to be very nearly on it. + const LLVector3 absurd(0.3f, 1.f, 0.3f); + const ALWhiteBalanceSolver::Result got = ALWhiteBalanceSolver::solve(absurd); + + ensure("cct stays in range", + got.mCCTOffset >= ALWhiteBalanceSolver::CCT_MIN && + got.mCCTOffset <= ALWhiteBalanceSolver::CCT_MAX); + ensure("duv stays in range", + got.mDuv >= ALWhiteBalanceSolver::DUV_MIN && + got.mDuv <= ALWhiteBalanceSolver::DUV_MAX); + ensure("and it reports that it could not get there", got.mResidual > 0.1f); + } + + // The gain that neutralises a colour, and the sign of what it does. + template<> template<> + void wb_object::test<6>() + { + const LLVector3 g = ALWhiteBalanceSolver::neutralisingGain(LLColor3(0.4f, 0.5f, 0.8f)); + ensure_approximately_equals("green pinned", g.mV[VY], 1.f, 6); + ensure_approximately_equals("red is lifted to meet green", g.mV[VX], 1.25f, 5); + ensure_approximately_equals("blue is pulled down to it", g.mV[VZ], 0.625f, 5); + + // Applying it does what it says. + const LLColor3 sample(0.4f, 0.5f, 0.8f); + ensure_approximately_equals("corrected red equals green", sample.mV[0] * g.mV[VX], sample.mV[1], 5); + ensure_approximately_equals("corrected blue equals green", sample.mV[2] * g.mV[VZ], sample.mV[1], 5); + + // Scale-invariant: the eyedropper cares about the colour of a sample, + // never how brightly it was lit. + const LLVector3 dim = ALWhiteBalanceSolver::neutralisingGain(LLColor3(0.04f, 0.05f, 0.08f)); + ensure("brightness does not change the answer", gainDistance(g, dim) < 1e-4f); + } + + // The whole eyedropper, end to end: take a neutral surface, light it the + // way some (cct, duv) pair would, and the solver should recover the pair + // that undoes it. + template<> template<> + void wb_object::test<7>() + { + for (S32 i = -3; i <= 3; ++i) + { + const F32 cct = i * 1500.f; + + // A grey surface seen through the inverse of the correction: this + // is what the scene buffer holds when the light is that colour. + const LLVector3 correction = ALWhiteBalanceSolver::gain(cct, 0.f); + const LLColor3 lit(0.5f / correction.mV[VX], 0.5f, 0.5f / correction.mV[VZ]); + + const ALWhiteBalanceSolver::Result got = ALWhiteBalanceSolver::solveForColor(lit); + const LLVector3 back = ALWhiteBalanceSolver::gain(got.mCCTOffset, got.mDuv); + + ensure("recovers the light's own correction", + gainDistance(correction, back) < 1e-3f); + + // And the sample really does come out neutral. + const LLColor3 fixed(lit.mV[0] * back.mV[VX], lit.mV[1], lit.mV[2] * back.mV[VZ]); + ensure_approximately_equals("neutralised red", fixed.mV[0], fixed.mV[1], 4); + ensure_approximately_equals("neutralised blue", fixed.mV[2], fixed.mV[1], 4); + } + } + + // An already-neutral sample asks for nothing, so clicking a grey wall in + // a correctly balanced scene must not nudge the sliders off zero. + template<> template<> + void wb_object::test<8>() + { + const ALWhiteBalanceSolver::Result got = + ALWhiteBalanceSolver::solveForColor(LLColor3(0.5f, 0.5f, 0.5f)); + ensure("cct stays put", std::fabs(got.mCCTOffset) < 1.f); + ensure("duv stays put", std::fabs(got.mDuv) < 1e-3f); + ensure("exactly reachable", got.mResidual < 1e-4f); + } + + // The warm end of the Temperature slider leaves the sRGB gamut: below + // roughly 1900K the locus is outside it and the XYZ-to-sRGB matrix returns + // a negative blue gain, which multiplied into a frame flips the channel's + // sign. That is the renderer's existing behaviour and this fixes none of + // it; what is pinned here is that the solver stays out of the region, so + // the eyedropper can never hand a user a balance that does that. + template<> template<> + void wb_object::test<9>() + { + ensure("neutral is usable", ALWhiteBalanceSolver::isUsable(0.f, 0.f)); + ensure("the cool end is usable", ALWhiteBalanceSolver::isUsable(5000.f, 0.f)); + ensure("the warm extreme is not", !ALWhiteBalanceSolver::isUsable(-5000.f, 0.f)); + // Tint moves the boundary: pushing green costs gamut at the warm end. + ensure("-4000 is usable at neutral tint", ALWhiteBalanceSolver::isUsable(-4000.f, 0.f)); + ensure("but not at full green tint", !ALWhiteBalanceSolver::isUsable(-4000.f, 1.f)); + + // Ask for something only the out-of-gamut region could match, and the + // answer must still be a balance that works. + const LLVector3 want = ALWhiteBalanceSolver::gain(-5000.f, 0.f); + const ALWhiteBalanceSolver::Result got = ALWhiteBalanceSolver::solve(want); + ensure("the solution is usable", ALWhiteBalanceSolver::isUsable(got.mCCTOffset, got.mDuv)); + const LLVector3 back = ALWhiteBalanceSolver::gain(got.mCCTOffset, got.mDuv); + ensure("its red gain is positive", back.mV[VX] > 0.f); + ensure("and so is its blue", back.mV[VZ] > 0.f); + } +} diff --git a/indra/newview/tests/lutcube_test.cpp b/indra/newview/tests/lutcube_test.cpp new file mode 100644 index 0000000000..b4f183c19e --- /dev/null +++ b/indra/newview/tests/lutcube_test.cpp @@ -0,0 +1,292 @@ +/** + * @file lutcube_test.cpp + * @brief Unit tests for the .cube LUT parser + * + * Copyright (c) 2026, Alchemy Viewer Project. + * + * The source code in this file is provided to you under the terms of the + * GNU Lesser General Public License, version 2.1, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A + * PARTICULAR PURPOSE. Terms of the LGPL can be found in doc/LGPL-licence.txt + * in this distribution, or online at http://www.gnu.org/licenses/lgpl-2.1.txt + * + * A .cube is untrusted input -- a file the user downloaded and pointed us at. + * Most of what is below is therefore about what the parser does with a file + * that is wrong, not one that is right. + */ + +#include "linden_common.h" + +#include "../test/lltut.h" + +#include "../lutcube.h" + +#include + +namespace tut +{ + struct lutcube_data + { + /// A well-formed identity cube of side @a n, optionally with @a extra + /// header lines (DOMAIN_MIN and friends) inserted after the size. + /// Entries run with red fastest, which is the .cube convention and the + /// order LutCube advances its cursor in. + static std::string identity(int n, const std::string& extra = std::string()) + { + std::ostringstream out; + out << "# generated by lutcube_test\n"; + out << "TITLE \"identity\"\n"; + out << "LUT_3D_SIZE " << n << "\n"; + if (!extra.empty()) + { + out << extra << "\n"; + } + const float last = (float)(n - 1); + for (int z = 0; z < n; ++z) + { + for (int y = 0; y < n; ++y) + { + for (int x = 0; x < n; ++x) + { + out << (F32)x / last << " " << (F32)y / last << " " << (F32)z / last << "\n"; + } + } + } + return out.str(); + } + + static LutCube parse(const std::string& text) + { + LutCube cube; + std::istringstream stream(text); + cube.parse(stream); + return cube; + } + + /// Value at entry @a index of channel @a channel (0 = R, 1 = G, 2 = B). + /// Entries are 16-bit, so full scale is 65535 and mid grey is 32768. + static int at(const LutCube& cube, int index, int channel) + { + return (int)cube.colorCube[(size_t)index * 4 + channel]; + } + + // constexpr, not const: ensure_equals takes its arguments by reference, + // which would odr-use a plain static const and want a definition. + static constexpr int FULL = 65535; + static constexpr int MID = 32768; + }; + + typedef test_group lutcube_group; + typedef lutcube_group::object lutcube_object; + tut::lutcube_group lcg("LutCube"); + + // The ordinary case, and the layout the uploader depends on: red varies + // fastest, so entry 1 of a size-2 cube is (FULL, 0, 0). + template<> template<> + void lutcube_object::test<1>() + { + const LutCube cube = parse(identity(2)); + ensure_equals("size", cube.size, 2); + ensure_equals("entry count", (int)cube.colorCube.size(), 2 * 2 * 2 * 4); + + ensure_equals("black R", at(cube, 0, 0), 0); + ensure_equals("black G", at(cube, 0, 1), 0); + ensure_equals("black B", at(cube, 0, 2), 0); + + ensure_equals("red is next R", at(cube, 1, 0), FULL); + ensure_equals("red is next G", at(cube, 1, 1), 0); + ensure_equals("red is next B", at(cube, 1, 2), 0); + + ensure_equals("white is last R", at(cube, 7, 0), FULL); + ensure_equals("white is last G", at(cube, 7, 1), FULL); + ensure_equals("white is last B", at(cube, 7, 2), FULL); + } + + // Quantisation rounds rather than truncates. 0.5 * 65535 is 32767.5; + // truncating biases every entry in the cube downwards by up to one step, + // which on an identity LUT is a darkening for no reason at all. + template<> template<> + void lutcube_object::test<2>() + { + const LutCube cube = parse(identity(3)); + ensure_equals("size", cube.size, 3); + // Entry 1 is x = 1 of 2, i.e. exactly mid grey on red alone. + ensure_equals("mid rounds up", at(cube, 1, 0), MID); + } + + // DOMAIN_MIN was parsed and then ignored: the original divided by the span + // but never subtracted the offset. With a domain of [-1, 1], an input of 0 + // is the middle of the domain and must land on mid grey -- the old code put + // it at 0. + template<> template<> + void lutcube_object::test<3>() + { + std::ostringstream text; + text << "LUT_3D_SIZE 2\n"; + text << "DOMAIN_MIN -1.0 -1.0 -1.0\n"; + text << "DOMAIN_MAX 1.0 1.0 1.0\n"; + for (int i = 0; i < 8; ++i) + { + text << "0.0 0.0 0.0\n"; + } + + const LutCube cube = parse(text.str()); + ensure_equals("size", cube.size, 2); + ensure_equals("domain centre is mid grey", at(cube, 0, 0), MID); + ensure_equals("domain centre is mid grey", at(cube, 0, 1), MID); + ensure_equals("domain centre is mid grey", at(cube, 0, 2), MID); + } + + // The one that shows up as an artefact rather than a failure. "clampTripel" + // did not clamp, so 1.1 became 255 * 1.1 = 280, which wrapped to 24 on the + // cast -- a blown highlight rendered as near-black speckle. Below the + // domain wrapped the other way. + template<> template<> + void lutcube_object::test<4>() + { + std::ostringstream text; + text << "LUT_3D_SIZE 2\n"; + for (int i = 0; i < 8; ++i) + { + text << "1.1 -0.4 0.5\n"; + } + + const LutCube cube = parse(text.str()); + ensure_equals("above domain clamps to white", at(cube, 0, 0), FULL); + ensure_equals("below domain clamps to black", at(cube, 0, 1), 0); + ensure_equals("in domain is untouched", at(cube, 0, 2), MID); + } + + // Legal .cube files indent their data, and the original required a digit at + // column zero. A rejected row does not merely lose itself: every later row + // slides up into its slot, so the whole cube is wrong and the tail is left + // at the full-scale fill. Indented, signed and bare-decimal rows all count. + template<> template<> + void lutcube_object::test<5>() + { + const LutCube plain = parse(identity(2)); + + std::ostringstream text; + text << "LUT_3D_SIZE 2\n"; + text << " 0.0 0.0 0.0\n"; // leading spaces + text << "\t+1.0 0.0 0.0\n"; // tab, and an explicit plus + text << " .0 1.0 0.0\n"; // bare decimal point + text << "1.0 1.0 0.0\n"; + text << "0.0 0.0 1.0\n"; + text << "1.0 0.0 1.0\n"; + text << "0.0 1.0 1.0\n"; + text << "1.0 1.0 1.0\n"; + + const LutCube indented = parse(text.str()); + ensure_equals("indented cube is accepted", indented.size, 2); + ensure("indented cube matches the plain one", indented.colorCube == plain.colorCube); + } + + // The memory-safety one. With no LUT_3D_SIZE the cube is never allocated and + // size stays zero, and the original wrote the first data row into it + // regardless. Rejecting the file is the whole fix; the assertion here is + // that we get an empty cube back and not a crash. + template<> template<> + void lutcube_object::test<6>() + { + const LutCube cube = parse("0.0 0.0 0.0\n1.0 1.0 1.0\n"); + ensure("data before LUT_3D_SIZE is rejected", cube.colorCube.empty()); + ensure_equals("and leaves no size", cube.size, 0); + } + + // A short file used to be kept. The allocation is prefilled with full + // scale, so everything past the last row read came out white -- an "almost + // working" LUT that blows out the top of the image. + template<> template<> + void lutcube_object::test<7>() + { + std::string text = identity(2); + text.erase(text.find_last_of('\n', text.size() - 2) + 1); + + const LutCube cube = parse(text); + ensure("an incomplete cube is rejected", cube.colorCube.empty()); + } + + // stof/stoi throw, and nothing used to catch them, so a corrupt file threw + // out of setupGradingLUT rather than falling back. + template<> template<> + void lutcube_object::test<8>() + { + const LutCube bad_size = parse("LUT_3D_SIZE banana\n"); + ensure("a non-numeric size is rejected", bad_size.colorCube.empty()); + + std::ostringstream text; + text << "LUT_3D_SIZE 2\n"; + text << "0.0 0.0 0.0\n"; + text << "0.5 what 0.5\n"; + const LutCube bad_row = parse(text.str()); + ensure("a malformed row is rejected", bad_row.colorCube.empty()); + } + + // An absurd size should be refused before it is allocated, not after. + template<> template<> + void lutcube_object::test<9>() + { + ensure("a huge size is rejected", parse("LUT_3D_SIZE 100000\n").colorCube.empty()); + ensure("a degenerate size is rejected", parse("LUT_3D_SIZE 1\n").colorCube.empty()); + ensure("a negative size is rejected", parse("LUT_3D_SIZE -4\n").colorCube.empty()); + } + + // Values separated by runs of spaces or by tabs, and a file with CRLF line + // endings read somewhere that does not strip the CR. The original split on + // the first " \n" it found and lost a value to any of these. + template<> template<> + void lutcube_object::test<10>() + { + const LutCube plain = parse(identity(2)); + + std::ostringstream text; + text << "LUT_3D_SIZE 2\r\n"; + text << "0.0\t0.0\t0.0\r\n"; + text << "1.0 0.0 0.0\r\n"; + text << "0.0 1.0 0.0\r\n"; + text << "1.0 1.0 0.0\r\n"; + text << "0.0 0.0 1.0\r\n"; + text << "1.0 0.0 1.0\r\n"; + text << "0.0 1.0 1.0\r\n"; + text << "1.0 1.0 1.0\r\n"; + + const LutCube odd = parse(text.str()); + ensure_equals("odd whitespace is accepted", odd.size, 2); + ensure("odd whitespace matches the plain cube", odd.colorCube == plain.colorCube); + } + + // A value the text can carry but the arithmetic cannot survive. Whether a + // stream accepts "nan" or "inf" as a float varies by standard library, so + // the parser must reject them itself rather than lean on the extraction + // failing: a NaN sails through llclamp (both comparisons are false) and + // the cast to unsigned short is undefined behaviour. + template<> template<> + void lutcube_object::test<11>() + { + static const char* const poison[] = { "nan", "inf", "-inf" }; + for (const char* value : poison) + { + std::ostringstream text; + text << "LUT_3D_SIZE 2\n"; + text << "1.0 " << value << " 0.5\n"; + for (int i = 0; i < 7; ++i) + { + text << "0.0 0.0 0.0\n"; + } + ensure(std::string("a ") + value + " value is rejected", + parse(text.str()).colorCube.empty()); + } + + // The domain lines go through the same reader, and a non-finite domain + // poisons the quantisation of every row instead of just one. + std::ostringstream domain; + domain << "LUT_3D_SIZE 2\n"; + domain << "DOMAIN_MAX inf inf inf\n"; + for (int i = 0; i < 8; ++i) + { + domain << "0.0 0.0 0.0\n"; + } + ensure("a non-finite domain is rejected", parse(domain.str()).colorCube.empty()); + } +} diff --git a/indra/newview/viewer_manifest.py b/indra/newview/viewer_manifest.py index 520ce200c0..072ec05b02 100755 --- a/indra/newview/viewer_manifest.py +++ b/indra/newview/viewer_manifest.py @@ -104,6 +104,9 @@ def construct(self): # ... and the entire color grading LUT directory self.path("colorlut") + # ... and the bundled starter Looks + self.path("looks") + # Poser Presets self.path("poses")