MONKEY: lighting round 6 — Advanced Graphics options page, moon shadows, ground-effect spell lights

- Advanced Graphics page: Lighting Quality preset (Off/Low/Medium/High/Custom, one const table,
  Off = the original client's look), shadow rows moved from Graphics, Shadow Resolution, Moon
  Shadows, interior/torch/fire/spell/night rows; EN+RU strings; works in player builds.
- Moon shadows: the single shadow rig re-aims at the moon at night (MoonShadowStrength 0.35);
  attenuates only the night sky term, never point lights; covers terrain, WMO exteriors, models
  and retained doodads; stateful hand-over for clock jumps; blob gated on a built silhouette;
  zero cost at strength 0. Signed fog_params.z carries +sun / -moon*strength.
- Area spell lights: SpellLightMode::Area for Flamestrike, Rain of Fire, Consecration, Flare,
  Explosive Trap (school-based where the model says nothing); Blizzard/Hurricane/Volley/frost
  traps stay dark; repeating impacts deduped; benilla-extract spellvis prints the area block.
1121 world+formats tests; 8/8 WGSL probes; UI and world smoke scenes clean.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
pkuzic
2026-09-18 14:32:04 +01:00
co-authored by Claude Fable 5.1
parent 839bc58d1b
commit 277deac06b
26 changed files with 3180 additions and 188 deletions
+671 -54
View File
@@ -329,9 +329,12 @@
-- The static category tree (era Game tab, Classic tocs), in list order. Keys are the row
-- name suffixes; the visible label doubles as the page title (the era does the same —
-- Settings.RegisterCategory names both from one string).
-- MONKEY (advanced graphics): "AdvancedGraphics" joins the System group under Graphics —
-- the seat the era's own tree gives a page that refines the one above it, and the seat
-- that keeps the three shadow rows it inherits one row away from where they used to be.
OPTIONS_CATEGORY_KEYS = {
"Controls", "Interface", "ActionBars", "Combat", "Chat", "Keybindings", "Nameplates",
"Graphics", "Audio",
"Graphics", "AdvancedGraphics", "Audio",
}
-- The era-window scale (shared, director-owned — the 0219 WORLDMAP_WINDOWED_SCALE
@@ -804,19 +807,265 @@
.. "which it could only do in exclusive fullscreen. benilla applies it to the "
.. "finished image instead, so it works in both display modes."
BENILLA_TOOLTIP_CHARACTER_SHADOWS =
"Renders real dynamic silhouettes for players, NPCs, creatures, and mounts, replacing "
.. "the legacy oval blob. Matches Wrath's character-only shadow tier. This uses "
.. "additional GPU time and memory."
-- ══ MONKEY (advanced graphics): the Advanced Graphics page's own strings, EN + RU ═════
--
-- The page that gives a PLAYER the dynamic light and shadow system. Until it existed the
-- lanes were reachable only from `/console` and the native debug panel — and the panel
-- does not ship in a player build, so a player had no way in at all. Three rows move here
-- off the Graphics page (Real Character Shadows, World Shadows, Shadow Distance) with
-- their settings and their descriptions unchanged; the other twelve are new seats on
-- CVars this client has read for some time.
--
-- **Every string on it is authored in both languages** (the project's own standing rule),
-- picked by `GetLocale()`, and there is no other localisation pattern in this window to
-- follow: every page above is 1.12 GlobalStrings, whose own file IS the localisation. So
-- the table is here, next to the page, rather than in a scheme of its own.
--
-- TWO CAVEATS, both true of the client and not of this table: benilla's `GetLocale()`
-- answers `enUS` unconditionally today (it ships and reads enUS data only), so the ruRU
-- column is dormant until that changes; and 1.12's own `FRIZQT__.TTF` carries no Cyrillic
-- glyphs, so a Russian build needs a font that does before these read as words. Neither is
-- a reason to ship the page half-authored — both are one line away from being true, and a
-- string written later is a string written to a different standard.
--
-- The VALUES the rows write are never localised: a dropdown entry carries the engine's own
-- string in `value` and the player's language in `text`, so `config.toml` and `/console`
-- read the same words on every install.
BENILLA_ADVGFX_STRINGS = {
enUS = {
page = "Advanced Graphics",
lightingQuality = "Lighting Quality",
qOff = "Off",
qLow = "Low",
qMedium = "Medium",
qHigh = "High",
qCustom = "Custom",
characterShadows = "Real Character Shadows",
worldShadows = "World Shadows",
shadowDistance = "Shadow Distance",
shadowResolution = "Shadow Resolution",
moonShadows = "Moon Shadows",
interiorLight = "Dynamic Interior Lighting",
interiorShadows = "Indoor Torch Shadows",
exteriorShadows = "Outdoor Torch Shadows",
torchSoft = "Torch Shadow Softness",
fireLight = "Fire Light Brightness",
fireFlicker = "Fire Flicker",
spellLights = "Spell Lights",
nightDarkness = "Night Darkness",
interiorDarkness = "Interior Darkness",
yards = "%d yd",
tips = {
LIGHTING_QUALITY =
"Sets character, world, indoor torch and outdoor torch shadows; dynamic "
.. "interior lighting; moon shadows; fire and spell brightness; fire flicker; "
.. "and night and interior darkness. Low, Medium and High also set shadow "
.. "resolution; Medium and High also set resident and moving torch shadow counts.\n\n"
.. "Off restores the original client's look: no synthesised fire lights, "
.. "night gain 1.0 and interior gain 1.0. All other settings, including "
.. "Shadow Distance and Torch Shadow Softness, stay as you set them. "
.. "Changing a preset member shows Custom when no preset matches.",
CHARACTER_SHADOWS =
"Renders real dynamic silhouettes for players, NPCs, creatures, and mounts, replacing "
.. "the legacy oval blob. Matches Wrath's character-only shadow tier. This uses "
.. "additional GPU time and memory.",
WORLD_SHADOWS =
"Casts real dynamic shadows from the static world (trees, buildings, and foliage) "
.. "following the sun, and switches off the baked terrain shadows they replace. "
.. "Independent of Real Character Shadows. This uses additional GPU time and memory.",
SHADOW_DISTANCE =
"How far realtime shadows are drawn, in yards. Higher covers more ground but spreads "
.. "the shadow map thinner (softer edges) and costs more. Applies on Apply.",
SHADOW_RESOLUTION =
"The edge of the sun's shadow map, in texels. Larger is sharper "
.. "everywhere the shadow distance reaches.\n\n"
.. "The cost is quadratic: 4096 draws four times the shadow pass of 2048 "
.. "and holds four times the depth texture.",
MOON_SHADOWS =
"How dark a shadow thrown by moonlight is allowed to get. A little keeps "
.. "the night from reading flat; a lot is stylised moonlight the original "
.. "client never had.\n\n"
.. "At 0 the moon casts nothing at all and the night renders exactly as "
.. "it did before this existed. It costs nothing by day.",
INTERIOR_LIGHT =
"Lights the inside of buildings from the lamps, candles and hearths that "
.. "actually stand in the room, instead of the lighting baked into the "
.. "walls.\n\n"
.. "Off restores the original client's baked interiors. On, it costs a "
.. "little GPU time per lit surface. Indoor Torch Shadows require it; "
.. "Outdoor Torch Shadows work independently.",
INTERIOR_SHADOWS =
"Lets the nearest indoor fires cast real shadows — a candle throws the "
.. "table's shadow across the floor.\n\n"
.. "Each promoted fire costs a shadow map of its own, which makes this "
.. "the most expensive row on the page. Needs Dynamic Interior Lighting.",
EXTERIOR_SHADOWS =
"The same for fires out in the open: campfires, braziers and lampposts "
.. "cast real shadows after dark.\n\n"
.. "It does nothing by day and does not require Dynamic Interior Lighting.",
TORCH_SOFT =
"How soft the edge of a torch shadow is where the object meets the "
.. "ground. Low is hard and crisp; high spreads the edge the way a real "
.. "flame does.\n\n"
.. "It costs no extra shadow maps — only a wider filter over the ones "
.. "already drawn.",
FIRE_LIGHT =
"How brightly campfires, torches, braziers and forges light the world "
.. "around them. These are lights benilla invents from the flame itself; "
.. "the original client's art carries none.\n\n"
.. "At 0 the invented fire lights are off and only the lights the artists "
.. "placed remain. Moving it costs nothing.",
FIRE_FLICKER =
"How strongly every flame's light breathes — candles quick and shallow, "
.. "bonfires slow and steady.\n\n"
.. "At 0 fires burn at a constant brightness, which is what they did "
.. "before this existed. It is computed on the processor and costs the "
.. "graphics card nothing.",
SPELL_LIGHTS =
"How brightly spells, missiles and impacts light the room around them — a "
.. "fireball's flash, an aura's glow.\n\n"
.. "Separate from Fire Light Brightness on purpose: calming a busy fight "
.. "should not dim the world's hearths. At 0 no spell throws light.",
NIGHT_DARKNESS =
"How dark the world gets outdoors after sunset. "
.. "1.0 = the original client night; lower = darker.\n\n"
.. "It never touches the fires — the point is a darker night around the "
.. "flame, not a dimmer flame. Inert while the sun is up, and free at any "
.. "setting.",
INTERIOR_DARKNESS =
"Scales interior fixture light and room ambient, including the light from "
.. "lamps and candles. Lower values make both dimmer.\n\n"
.. "1.0 leaves these inputs unchanged. Free at any setting.",
},
},
ruRU = {
page = "Расширенная графика",
lightingQuality = "Качество освещения",
qOff = "Выкл.",
qLow = "Низкое",
qMedium = "Среднее",
qHigh = "Высокое",
qCustom = "Своё",
characterShadows = "Настоящие тени персонажей",
worldShadows = "Тени мира",
shadowDistance = "Дальность теней",
shadowResolution = "Разрешение теней",
moonShadows = "Лунные тени",
interiorLight = "Динамическое освещение помещений",
interiorShadows = "Тени факелов в помещениях",
exteriorShadows = "Тени огней под открытым небом",
torchSoft = "Мягкость теней от огня",
fireLight = "Яркость огня",
fireFlicker = "Мерцание огня",
spellLights = "Свет от заклинаний",
nightDarkness = "Темнота ночи",
interiorDarkness = "Темнота помещений",
yards = "%d ярд.",
tips = {
LIGHTING_QUALITY =
"Задаёт тени персонажей, мира и огней внутри и снаружи помещений; "
.. "динамическое освещение помещений; лунные тени; яркость огня и заклинаний; "
.. "мерцание огня; темноту ночи и помещений. «Низкое», «Среднее» и «Высокое» "
.. "также задают разрешение теней; «Среднее» и «Высокое» — число карт теней "
.. "от огней и число огней с тенями движущихся объектов.\n\n"
.. "«Выкл.» возвращает вид оригинального клиента: без созданных из пламени "
.. "источников света, с коэффициентами яркости ночи и помещений 1.0. "
.. "Все остальные настройки, включая дальность теней и мягкость теней от огня, "
.. "сохраняют ваши значения. Изменение настройки набора показывает «Своё», "
.. "если ни один набор больше не совпадает.",
CHARACTER_SHADOWS =
"Рисует настоящие динамические силуэты игроков, НИП, существ и ездовых "
.. "животных вместо старого овального пятна. Соответствует уровню теней "
.. "персонажей из Wrath. Требует дополнительного времени и памяти "
.. "видеокарты.",
WORLD_SHADOWS =
"Заставляет статичный мир — деревья, здания и растительность — "
.. "отбрасывать настоящие динамические тени вслед за солнцем и отключает "
.. "запечённые тени ландшафта, которые они заменяют. Не зависит от "
.. "настоящих теней персонажей. Требует дополнительного времени и памяти "
.. "видеокарты.",
SHADOW_DISTANCE =
"Как далеко рисуются тени в реальном времени, в ярдах. Большее значение "
.. "покрывает больше земли, но растягивает карту теней тоньше (края "
.. "становятся мягче) и стоит дороже. Применяется по кнопке «Применить».",
SHADOW_RESOLUTION =
"Размер карты теней солнца в текселях. Чем больше, тем чётче тени на "
.. "всей дальности их прорисовки.\n\n"
.. "Стоимость растёт квадратично: 4096 — это вчетверо больше работы в "
.. "проходе теней и вчетверо больше видеопамяти под карту глубины, чем "
.. "2048.",
MOON_SHADOWS =
"Насколько тёмной может стать тень, отбрасываемая лунным светом. "
.. "Немного — и ночь перестаёт выглядеть плоской; много — это "
.. "стилизованная луна, какой в оригинальном клиенте не было.\n\n"
.. "При 0 луна не отбрасывает теней вовсе, и ночь выглядит так же, как "
.. "до появления этой возможности. Днём не стоит ничего.",
INTERIOR_LIGHT =
"Освещает помещения от ламп, свечей и очагов, которые действительно "
.. "стоят в комнате, а не от освещения, запечённого в стенах.\n\n"
.. "«Выкл.» возвращает запечённые интерьеры оригинального клиента. "
.. "Включённый режим стоит немного времени видеокарты на каждую "
.. "освещённую поверхность. Тени от огней внутри требуют этого режима; "
.. "тени от огней снаружи работают независимо.",
INTERIOR_SHADOWS =
"Позволяет ближайшим огням в помещении отбрасывать настоящие тени: "
.. "свеча кладёт тень стола на пол.\n\n"
.. "Каждый такой огонь стоит собственной карты теней, поэтому это самая "
.. "дорогая строка на странице. Требует динамического освещения "
.. "помещений.",
EXTERIOR_SHADOWS =
"То же для огней под открытым небом: костры, жаровни и фонари "
.. "отбрасывают настоящие тени после наступления темноты.\n\n"
.. "Днём не действует и не требует динамического освещения помещений.",
TORCH_SOFT =
"Насколько мягок край тени от огня там, где предмет касается пола. "
.. "Низкое значение — резкий край, высокое — размытый, как от "
.. "настоящего пламени.\n\n"
.. "Дополнительных карт теней не требует: это лишь более широкий фильтр "
.. "по уже нарисованным.",
FIRE_LIGHT =
"Насколько ярко костры, факелы, жаровни и горны освещают всё вокруг. "
.. "Эти источники benilla создаёт из самого пламени — в оригинальной "
.. "графике их нет.\n\n"
.. "При 0 придуманные огни выключены и остаются лишь источники, "
.. "расставленные художниками. Изменение значения ничего не стоит.",
FIRE_FLICKER =
"Насколько сильно «дышит» свет каждого пламени: свечи — часто и слабо, "
.. "костры — медленно и ровно.\n\n"
.. "При 0 огни горят с постоянной яркостью, как было до появления этой "
.. "возможности. Считается на процессоре и ничего не стоит видеокарте.",
SPELL_LIGHTS =
"Насколько ярко заклинания, снаряды и попадания освещают всё вокруг: "
.. "вспышка огненного шара, свечение ауры.\n\n"
.. "Отделено от яркости огня намеренно: успокоить шумный бой не должно "
.. "значить пригасить очаги во всём мире. При 0 заклинания не дают "
.. "света.",
NIGHT_DARKNESS =
"Насколько темно становится под открытым небом после заката. "
.. "1.0 — ночь оригинального клиента; ниже — темнее.\n\n"
.. "Огней не касается: смысл в том, чтобы ночь вокруг пламени была "
.. "темнее, а не само пламя тусклее. Пока солнце в небе, не действует, и "
.. "ничего не стоит при любом значении.",
INTERIOR_DARKNESS =
"Масштабирует свет светильников и фоновое освещение помещений, включая "
.. "свет ламп и свечей. Меньшие значения ослабляют и то и другое.\n\n"
.. "1.0 оставляет эти составляющие без изменений. Ничего не стоит при любом значении.",
},
},
}
BENILLA_TOOLTIP_WORLD_SHADOWS =
"Casts real dynamic shadows from the static world (trees, buildings, and foliage) "
.. "following the sun, and switches off the baked terrain shadows they replace. "
.. "Independent of Real Character Shadows. This uses additional GPU time and memory."
-- The selected column. `or` the English one so an unbuilt locale is a fallback rather
-- than fifteen rows of `nil` labels — 1.12's own GlobalStrings do the same thing by
-- shipping one file per locale with every key present.
BENILLA_ADVGFX = BENILLA_ADVGFX_STRINGS[GetLocale()] or BENILLA_ADVGFX_STRINGS.enUS
BENILLA_TOOLTIP_SHADOW_DISTANCE =
"How far realtime shadows are drawn, in yards. Higher covers more ground but spreads "
.. "the shadow map thinner (softer edges) and costs more. Applies on Okay/Apply."
-- A row's description resolves by GLOBAL NAME at hover time (`OptionsRow_ShowTooltip`), so
-- the selected column's copy is published under the key the row carries. Same `BENILLA_`
-- prefix the four rows above use, and for the same reason: the reference's
-- `OPTION_TOOLTIP_` namespace stays the reference's.
for key, text in pairs(BENILLA_ADVGFX.tips) do
setglobal("BENILLA_TOOLTIP_" .. key, text)
end
OPTIONS_PAGE_ROWS = {
-- Seven rows became sixteen at 2180, and the nine that arrived were not new
@@ -851,9 +1100,25 @@
"RowUiScale", "RowFarclip", "RowWorldDetail", "RowBrightness",
"RowWeatherDensity",
"RowDisplayMode", "RowVerticalSync",
"RowCharacterShadows", "RowWorldShadows", "RowShadowDistance",
"RowRenderScale",
},
-- MONKEY (advanced graphics): the dynamic light + shadow system's own page. Order is
-- the preset first (it writes the rest), then the SUN lanes, then the ROOM lane and
-- its torches, then the invented lights, then the two darkness dials — coarse to
-- fine, and each group's own master above the rows it gates.
--
-- Real Character Shadows, World Shadows and Shadow Distance MOVED here from Graphics,
-- carrying their CVars and their descriptions unchanged: they belong with the rest of
-- the rig they share, and the Graphics page had become the place where a player found
-- three of eighteen shadow settings and no hint that the other fifteen existed.
AdvancedGraphics = {
"RowLightingQuality",
"RowCharacterShadows", "RowWorldShadows", "RowShadowDistance",
"RowShadowResolution", "RowMoonShadows",
"RowInteriorLight", "RowInteriorShadows", "RowExteriorShadows", "RowTorchSoft",
"RowFireLight", "RowFireFlicker", "RowSpellLights",
"RowNightDarkness", "RowInteriorDarkness",
},
-- Player Guild Names (2180) rides as a CHILD of Player Names, which is the
-- reference's own relationship twice over: its CheckButton22 hangs off 21 with a +26
-- x indent, and UIOptionsFrame_UpdateDependencies l.702-708 greys 22 (and 23) from
@@ -1030,7 +1295,7 @@
-- The write. A global's consumers are Lua and do not poll, so a uvar row also runs its
-- `applyFunc` — the reference's own per-uvar side effects (SIMPLE_CHAT → FCF_Set_*,
-- HIDE_PARTY_INTERFACE → RaidOptionsFrame_UpdatePartyFrames, the combat-text family →
-- CombatText_UpdateDisplayedMessages), moved from its batched Okay to our live write
-- CombatText_UpdateDisplayedMessages), moved from its batched confirmation to our live write
-- because this window commits on the click (0961's grammar), not on a Save button.
-- `cvarEvent` is a CVar row's counterpart to a global row's `applyFunc`, and it is the
-- reference's own third SetCVar argument (decision 1140): the token the engine hands back
@@ -1368,11 +1633,33 @@
return string.format("%d%%",
math.floor((value - row.minv) / (row.maxv - row.minv) * 100 + 0.5))
end
-- MONKEY (advanced graphics): travel, READ BACKWARDS — the same share of the groove,
-- counted from the right-hand end. The two darkness dials are the case: their stored
-- unit is a GAIN (`nightGain`, `interiorGain`), so the darkest setting is the SMALLEST
-- number and sits at the left of the groove, while the row is named for the darkness
-- rather than the gain. Without this the readout would count UP as the world got
-- LIGHTER under a label that says "Darkness", which is exactly backwards; with it the
-- label, the thumb and the number all agree that further left is darker. The store
-- keeps the gain, unchanged — this is a readout, not a unit.
if row.fmt == "darkness" and row.minv and row.maxv and row.maxv > row.minv then
return string.format("%d%%",
math.floor((row.maxv - value) / (row.maxv - row.minv) * 100 + 0.5))
end
-- A row whose stored value is a FACTOR but whose meaning is a distance: show the
-- distance (decision 1140 — cameraDistanceMaxFactor over the 15 yd base). The number
-- the player is choosing is "how far out the wheel stops", not "times 1.4".
if row.fmt == "yards" then
return string.format("%d yd", math.floor(value * 15 + 0.5))
return string.format(BENILLA_ADVGFX.yards, math.floor(value * 15 + 0.5))
end
-- MONKEY (advanced graphics): a row whose stored value ALREADY IS a distance in yards
-- — the realtime-shadow range. Its own format, because the one above it is a
-- CONVERSION and not a unit: `shadowDistance` rode `"yards"` from the day it was
-- seated, so an 80-yard cascade printed "1200 yd" and nothing caught it (no test read
-- that readout, and 1200 is not an absurd-looking number for a view distance). The
-- two formats differ by exactly the factor's 15 yd base, which is why they cannot be
-- one.
if row.fmt == "yd" then
return string.format(BENILLA_ADVGFX.yards, math.floor(value + 0.5))
end
return string.format("%d%%", math.floor(value * 100 + 0.5))
end
@@ -1533,6 +1820,47 @@
OptionsApply_Update()
end
-- ── MONKEY (advanced graphics): the page that has to WATCH its settings ─────────────
-- Every other page in this window owns its rows outright: nothing but a click on the page
-- moves the CVars behind it while it is open, so reading them once on select is the whole
-- contract. The lighting rows are not like that, and deliberately so — they are the live
-- A/B knobs the whole system was tuned with, and there are three other ways to move one
-- with this page on screen: `/console set nightGain 1` in chat, the native lighting panel
-- in a dev build, and — the one that matters to a player — the Lighting Quality row above,
-- which does not write its members here at all. It writes ONE CVar; the host's preset
-- table answers by writing the other ten, and those land a frame or two later.
--
-- So the page polls. A cheap signature of its own rows' values, four times a second, and a
-- refresh only when that string actually moves — which is never, on a page nobody is
-- touching. The alternative (an event) does not exist: a host-side CVar write reaches the
-- interface as a mirror update, not as `CVAR_UPDATE`, which the reference only ever fires
-- for a script's own `SetCVar` with a token.
--
-- Safe under a drag, and not by luck: the value the refresh would write back is the value
-- the drag just wrote, `SetValue` fires nothing when it does not change the value, and the
-- `refreshing` guard stops a write even if it did. A staged (deferred) edit is safe too —
-- `OptionsPage_Refresh` shows `row.pending` ahead of the stored value.
BENILLA_ADVGFX_POLL = 0.25
function AdvGfx_Signature()
local out = ""
for _, rkey in ipairs(OPTIONS_PAGE_ROWS.AdvancedGraphics) do
local row = getglobal("BenillaOptionsFrameContainerBodyAdvancedGraphics" .. rkey)
out = out .. "|" .. (OptionsRow_Get(row) or "")
end
return out
end
function AdvGfx_OnUpdate(page, elapsed)
page.since = (page.since or 0) + (elapsed or 0)
if page.since < BENILLA_ADVGFX_POLL then return end
page.since = 0
local sig = AdvGfx_Signature()
if sig == page.sig then return end
page.sig = sig
OptionsPage_Refresh("AdvancedGraphics")
end
-- Per-page dependency rules. Audio's is the 1.12 rule, verbatim: Enable All Sound off
-- greys Enable Ambience — and only it, of our rows
-- (SoundOptionsFrame_UpdateDependencies disables buttons 2 and 4, Ambience and Error
@@ -1575,6 +1903,27 @@
OptionsRow_SetEnabled(BenillaOptionsFrameContainerBodyCombatRowPetDamage, dmg)
return
end
if key == "AdvancedGraphics" then
-- MONKEY (advanced graphics): the page's own dependencies — the same shape the
-- reference's own rules have (a master's own switch greys what it gates), read
-- off the CVar docs rather than invented: `interiorShadows` "needs
-- interiorLight", the two shadow-rig rows size a cascade that is not rendered
-- with both sun lanes off, and the torch softness is the PCF radius on maps
-- neither torch lane is drawing. The CVars keep their values throughout — this
-- is a dependency, not a write, so turning a master back on finds its children
-- exactly where they were left.
local base = "BenillaOptionsFrameContainerBodyAdvancedGraphics"
local sun = GetCVar("characterShadows") ~= "0"
or GetCVar("worldShadows") ~= "0"
OptionsRow_SetEnabled(getglobal(base .. "RowShadowDistance"), sun)
OptionsRow_SetEnabled(getglobal(base .. "RowShadowResolution"), sun)
local interior = GetCVar("interiorLight") ~= "0"
OptionsRow_SetEnabled(getglobal(base .. "RowInteriorShadows"), interior)
local torch = (interior and GetCVar("interiorShadows") ~= "0")
or GetCVar("exteriorShadows") ~= "0"
OptionsRow_SetEnabled(getglobal(base .. "RowTorchSoft"), torch)
return
end
if key == "Controls" then
-- 1.12's own rule (l.715-719): the Auto-Follow Speed slider is dead while the
-- following style is Never, because there is no follow to set a speed for. The
@@ -2346,8 +2695,16 @@
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentHeaderSystem" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-2"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>OptionsCategoryRow_OnLoad(self, "Graphics", "Graphics")</OnLoad></Scripts>
</Button>
<Button name="$parentRowAudio" inherits="OptionsCategoryRowTemplate">
<!-- MONKEY (advanced graphics): under Graphics, which is what it refines. Its
label is the one category label in this tree that is not a literal — the
page is bilingual, so the row (and the page title it doubles as) reads
the selected column like every other string on it. -->
<Button name="$parentRowAdvancedGraphics" inherits="OptionsCategoryRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentRowGraphics" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-2"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>OptionsCategoryRow_OnLoad(self, "AdvancedGraphics", BENILLA_ADVGFX.page)</OnLoad></Scripts>
</Button>
<Button name="$parentRowAudio" inherits="OptionsCategoryRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentRowAdvancedGraphics" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-2"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>OptionsCategoryRow_OnLoad(self, "Audio", "Audio")</OnLoad></Scripts>
</Button>
</Frames>
@@ -3099,48 +3456,18 @@
"OPTION_TOOLTIP_VERTICAL_SYNC")
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowCharacterShadows" inherits="OptionsCheckboxRowTemplate">
<!-- MONKEY (advanced graphics): Real Character Shadows,
World Shadows and Shadow Distance stood here between
Vertical Sync and Render Scale. They are the Advanced
Graphics page's now, unchanged — same CVars, same
labels, same descriptions, same deferred commit on the
slider — because they are three rows of one rig whose
other fifteen a player could not reach at all. -->
<Frame name="$parentRowRenderScale" inherits="OptionsSliderRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowVerticalSync" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowVerticalSync" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsRow_OnLoad(self, "characterShadows", "Real Character Shadows",
"BENILLA_TOOLTIP_CHARACTER_SHADOWS")
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowWorldShadows" inherits="OptionsCheckboxRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowCharacterShadows" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowCharacterShadows" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsRow_OnLoad(self, "worldShadows", "World Shadows",
"BENILLA_TOOLTIP_WORLD_SHADOWS")
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowShadowDistance" inherits="OptionsSliderRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowWorldShadows" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowWorldShadows" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
-- Realtime-shadow render distance (yd): the cascade
-- range + caster reach. 40-200, matching the CVar's
-- own clamp (SHADOW_DISTANCE_RANGE). DEFERRED to
-- Apply so a drag doesn't re-fit the cascade + recollect
-- casters on every tick (one rebuild on commit instead).
OptionsRow_OnLoad(self, "shadowDistance", "Shadow Distance",
"BENILLA_TOOLTIP_SHADOW_DISTANCE")
OptionsSliderRow_Setup(self, 40, 200, 10, "yards")
self.deferred = 1
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowRenderScale" inherits="OptionsSliderRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowShadowDistance" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowShadowDistance" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
-- Render Scale (decision 1639) -- benilla's own
-- row, no 1.12 counterpart: the reference renders
@@ -3199,6 +3526,288 @@
</OnShow>
</Scripts>
</Frame>
<!-- ══ MONKEY (advanced graphics): the ADVANCED GRAPHICS page ═══
The dynamic light and shadow system, as a page a player can
reach. Nothing here is a new mechanism: every row is a seat on
a CVar this client already registers, reads per frame and
persists, and the page is built out of the same three row
templates and the same OptionsRow_* store as every page above
it. What it adds is a way in — before it, a player build had
none, because the native lighting panel is a dev-build window
and `/console` is not a setting.
Order is coarse to fine, and each master sits above what it
gates: the preset (which writes the rest), the sun rig, the
room lane with its torches, the invented lights, the two
darkness dials. The three rows that moved here from Graphics
keep their CVars, labels, descriptions and — on Shadow
Distance — the deferred commit, verbatim.
Every label and description is bilingual (BENILLA_ADVGFX);
every VALUE written stays the engine's own string. -->
<Frame name="$parentAdvancedGraphics" hidden="true">
<Anchors>
<Anchor point="TOPLEFT"/>
<Anchor point="BOTTOMRIGHT"/>
</Anchors>
<Frames>
<Frame name="$parentRowLightingQuality" inherits="OptionsDropdownRowTemplate">
<Anchors>
<Anchor point="TOPLEFT"><Offset><AbsDimension x="10" y="-12"/></Offset></Anchor>
<Anchor point="TOPRIGHT"><Offset><AbsDimension x="-20" y="-12"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
-- The preset ladder. This row writes ONE CVar and
-- nothing else: the host's own table
-- (`cvars::LIGHTING_PRESETS`) answers the write by
-- setting the member rows, and re-derives this
-- row's value from them every frame — which is why
-- Custom is an entry the capsule can SHOW but
-- picking it writes nothing anyone keeps.
--
-- The table is deliberately NOT here. Three other
-- ways reach the same ladder (the console, the
-- native lighting panel, a hand-edited
-- config.toml), and a copy carried by this window
-- would be a copy the other three cannot see.
-- The page's OnUpdate poll is what brings the
-- member rows' new values back onto the screen.
OptionsRow_OnLoad(self, "lightingQuality",
BENILLA_ADVGFX.lightingQuality,
"BENILLA_TOOLTIP_LIGHTING_QUALITY")
OptionsDropdownRow_Setup(self, {
{ text = BENILLA_ADVGFX.qOff, value = "Off" },
{ text = BENILLA_ADVGFX.qLow, value = "Low" },
{ text = BENILLA_ADVGFX.qMedium, value = "Medium" },
{ text = BENILLA_ADVGFX.qHigh, value = "High" },
{ text = BENILLA_ADVGFX.qCustom, value = "Custom" },
})
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowCharacterShadows" inherits="OptionsCheckboxRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowLightingQuality" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowLightingQuality" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsRow_OnLoad(self, "characterShadows",
BENILLA_ADVGFX.characterShadows,
"BENILLA_TOOLTIP_CHARACTER_SHADOWS")
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowWorldShadows" inherits="OptionsCheckboxRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowCharacterShadows" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowCharacterShadows" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsRow_OnLoad(self, "worldShadows",
BENILLA_ADVGFX.worldShadows,
"BENILLA_TOOLTIP_WORLD_SHADOWS")
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowShadowDistance" inherits="OptionsSliderRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowWorldShadows" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowWorldShadows" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
-- Realtime-shadow render distance (yd): the cascade
-- range + caster reach. 40-200, matching the CVar's
-- own clamp (SHADOW_DISTANCE_RANGE). DEFERRED to
-- Apply so a drag doesn't re-fit the cascade + recollect
-- casters on every tick (one rebuild on commit instead).
OptionsRow_OnLoad(self, "shadowDistance",
BENILLA_ADVGFX.shadowDistance,
"BENILLA_TOOLTIP_SHADOW_DISTANCE")
-- `"yd"`, not `"yards"`: the value is already a
-- distance in yards, where the format beside it
-- multiplies a FACTOR by the 15 yd camera base.
-- This row rode the wrong one from the day it was
-- seated and printed "1200 yd" for an 80 yd
-- cascade.
OptionsSliderRow_Setup(self, 40, 200, 10, "yd")
self.deferred = 1
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowShadowResolution" inherits="OptionsDropdownRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowShadowDistance" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowShadowDistance" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
-- The three stops the CVar's own snap accepts
-- (`shadow_core::SHADOW_MAP_SIZES`): a dropdown
-- rather than a slider because an off-ladder value
-- is not a weaker setting, it is one the renderer
-- rounds UP into a bigger and slower map than the
-- one that was asked for. The entry texts are the
-- numbers themselves — nothing to translate.
OptionsRow_OnLoad(self, "shadowMapSize",
BENILLA_ADVGFX.shadowResolution,
"BENILLA_TOOLTIP_SHADOW_RESOLUTION")
OptionsDropdownRow_Setup(self, {
{ text = "1024", value = "1024" },
{ text = "2048", value = "2048" },
{ text = "4096", value = "4096" },
})
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowMoonShadows" inherits="OptionsSliderRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowShadowResolution" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowShadowResolution" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
-- 0..1, the CVar's own clamp, and 0 is the FAITHFUL
-- null rather than a floor: every consumer guards
-- on it, so the left end of this groove is the
-- night the client rendered before the moon lane
-- existed.
OptionsRow_OnLoad(self, "moonShadowStrength",
BENILLA_ADVGFX.moonShadows,
"BENILLA_TOOLTIP_MOON_SHADOWS")
OptionsSliderRow_Setup(self, 0, 1, 0.05)
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowInteriorLight" inherits="OptionsCheckboxRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowMoonShadows" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowMoonShadows" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsRow_OnLoad(self, "interiorLight",
BENILLA_ADVGFX.interiorLight,
"BENILLA_TOOLTIP_INTERIOR_LIGHT")
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowInteriorShadows" inherits="OptionsCheckboxRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowInteriorLight" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowInteriorLight" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsRow_OnLoad(self, "interiorShadows",
BENILLA_ADVGFX.interiorShadows,
"BENILLA_TOOLTIP_INTERIOR_SHADOWS")
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowExteriorShadows" inherits="OptionsCheckboxRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowInteriorShadows" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowInteriorShadows" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsRow_OnLoad(self, "exteriorShadows",
BENILLA_ADVGFX.exteriorShadows,
"BENILLA_TOOLTIP_EXTERIOR_SHADOWS")
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowTorchSoft" inherits="OptionsSliderRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowExteriorShadows" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowExteriorShadows" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
-- 0.5..3, the CVar's own clamp verbatim: it is a
-- SCALE on the PCF tap radius at a contact, so the
-- readout is a percentage of the shipped radius
-- and 100% is what the lane was tuned at.
OptionsRow_OnLoad(self, "interiorShadowSoft",
BENILLA_ADVGFX.torchSoft,
"BENILLA_TOOLTIP_TORCH_SOFT")
OptionsSliderRow_Setup(self, 0.5, 3, 0.1)
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowFireLight" inherits="OptionsSliderRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowTorchSoft" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowTorchSoft" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
-- Full 0..4 registry range, in five-percent steps.
OptionsRow_OnLoad(self, "fireLightGain",
BENILLA_ADVGFX.fireLight,
"BENILLA_TOOLTIP_FIRE_LIGHT")
OptionsSliderRow_Setup(self, 0, 4, 0.05)
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowFireFlicker" inherits="OptionsSliderRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowFireLight" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowFireLight" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
-- 0..2, the CVar's own clamp verbatim: the
-- amplitudes are authored at 100%, and 200% is the
-- deliberate over-drive for judging the shape.
OptionsRow_OnLoad(self, "fireFlicker",
BENILLA_ADVGFX.fireFlicker,
"BENILLA_TOOLTIP_FIRE_FLICKER")
OptionsSliderRow_Setup(self, 0, 2, 0.05)
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowSpellLights" inherits="OptionsSliderRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowFireFlicker" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowFireFlicker" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
-- Full 0..4 registry range, in five-percent steps.
OptionsRow_OnLoad(self, "spellLightGain",
BENILLA_ADVGFX.spellLights,
"BENILLA_TOOLTIP_SPELL_LIGHTS")
OptionsSliderRow_Setup(self, 0, 4, 0.05)
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowNightDarkness" inherits="OptionsSliderRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowSpellLights" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowSpellLights" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
-- 0.2..1.5, the CVar's own clamp verbatim: the
-- floor is 0.2 rather than 0 because a true 0 is
-- indistinguishable from a broken light pack, and
-- the "off" a player wants is the reference's own
-- night at gain 1.
--
-- The readout is `darkness` — travel read from the
-- right — because the stored unit is a GAIN while
-- the row is named for the darkness: left is
-- darker, and this is what makes the number say so
-- too. The store keeps the gain, untouched.
OptionsRow_OnLoad(self, "nightGain",
BENILLA_ADVGFX.nightDarkness,
"BENILLA_TOOLTIP_NIGHT_DARKNESS")
OptionsSliderRow_Setup(self, 0.2, 1.5, 0.05, "darkness")
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowInteriorDarkness" inherits="OptionsSliderRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowNightDarkness" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowNightDarkness" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
-- The room lane's own dim dial, same clamp and same
-- backwards readout as the night one above it.
OptionsRow_OnLoad(self, "interiorGain",
BENILLA_ADVGFX.interiorDarkness,
"BENILLA_TOOLTIP_INTERIOR_DARKNESS")
OptionsSliderRow_Setup(self, 0.2, 1.5, 0.05, "darkness")
</OnLoad></Scripts>
</Frame>
</Frames>
<!-- The poll (see AdvGfx_OnUpdate): this is the one page whose
settings move from outside it — the Lighting Quality row
writes its members through the host, and the console and
the dev lighting panel reach the same CVars while the page
is open. A signature compare four times a second, and a
refresh only when it actually moves. -->
<Scripts>
<OnUpdate>AdvGfx_OnUpdate(this, arg1)</OnUpdate>
</Scripts>
</Frame>
<!-- The COMBAT page (decision 1134) — the first page whose rows
write saved-variable GLOBALS rather than CVars, and the first
thing that can change any of the ~20 stock globals 1128
@@ -4379,6 +4988,14 @@
getglobal(self:GetName() .. "Label"):SetText("Graphics")
</OnLoad></Scripts>
</Button>
<!-- MONKEY (advanced graphics): its own head, like every page with
rows. The label is the page's own bilingual title. -->
<Button name="$parentSearchHeadAdvancedGraphics" inherits="OptionsSearchHeadTemplate">
<Scripts><OnLoad>
self.categoryKey = "AdvancedGraphics"
getglobal(self:GetName() .. "Label"):SetText(BENILLA_ADVGFX.page)
</OnLoad></Scripts>
</Button>
<Button name="$parentSearchHeadCombat" inherits="OptionsSearchHeadTemplate">
<Scripts><OnLoad>
self.categoryKey = "Combat"
+66 -31
View File
@@ -99,9 +99,12 @@ use bevy::prelude::*;
use crate::creature_anim::AnimData;
use crate::net::{Embodied, NetEntity};
use benilla_world::decal::{DecalFrame, WorldDecal};
use benilla_world::lighting::WowLighting;
// MONKEY (moon shadows): the oval now yields to a NIGHT cast as well as a day one, so it needs the
// same two numbers the packer weights that cast with.
use benilla_world::lighting::ShadowHandover;
use crate::character_shadow::CharacterShadowReady;
use crate::shadow_core::{ShadowFrame, ShadowSet};
use benilla_world::particles::buffer::{begin_effect_frame, EffectVertex};
use benilla_world::schedule::WorldStage;
use benilla_world::view::WorldCamera;
use benilla_world::wmo_portal::UnitWmoRoom;
@@ -158,14 +161,16 @@ impl Plugin for BlobShadowPlugin {
fn build(&self, app: &mut App) {
app.add_systems(Startup, setup_shadow_assets)
.add_systems(
Update,
PostUpdate,
(sync_shadows, update_shadows)
.chain()
// After net motion + input: the decal follows this frame's unit transforms.
.after(WorldStage::Input),
// MONKEY (moon shadows): after ALL Update work (resolve + strength bridge)
// and this frame's rig verdict. Push only after the blob has consumed it.
.after(ShadowSet::Rig)
.after(bevy::transform::TransformSystems::Propagate)
.before(push_shadows),
)
// The stream push: after the frame's stream clear (the caches were rebuilt in
// `Update`, so this is a pure copy).
// The stream push: after both the frame's stream clear and the blob cache rebuild.
.add_systems(PostUpdate, push_shadows.after(begin_effect_frame));
}
}
@@ -241,10 +246,12 @@ fn update_shadows(
shadow_assets: Option<Res<ShadowAssets>>,
images: Res<Assets<Image>>,
decals: WorldDecal,
// MONKEY (night blob): the live celestial sun, for the realtime lane's day strength — the SAME
// `celestial_dir` height `global_light` packs the shader's night fade from, so the oval's
// crossfade and the realtime shadow's fade are two halves of one number.
lighting: Res<WowLighting>,
// MONKEY (moon shadows): SAME acknowledged weight as the packed receivers, plus actual
// per-unit caster readiness. The cvar alone cannot promise a silhouette exists.
handover: Res<ShadowHandover>,
// Missing core/character plugins are a valid no-silhouette state: keep the oval there too.
shadow_frame: Option<Res<ShadowFrame>>,
ready: Option<Res<CharacterShadowReady>>,
// …and the camera, for the realtime lane's EDGE fade (its cascade ends at `shadowDistance`).
cam_pos: Query<&GlobalTransform, With<WorldCamera>>,
// The unit's render alpha, asked of the unit — see the alpha block below.
@@ -279,24 +286,9 @@ fn update_shadows(
// report, answerable from a log instead of a debugger.
mut census_at: Local<f32>,
) {
// `characterShadows 1`: the realtime shadow-map path owns OUTDOOR unit shadows — the per-unit
// gate in the loop below fades the oval out for outdoor units so they don't wear both, while
// INDOOR units keep it at full strength (the realtime sun shadow is gated out of interiors, so
// an indoor unit would otherwise have no ground shadow at all). (This keys on the CHARACTER
// lane, not the world lane: the oval is a character's shadow, so `worldShadows` alone must
// leave it be.) With the cvar off — the shipped default — that gate is a single false branch
// and this is the reference path.
//
// MONKEY (night blob): it is a FADE, not the hard hide it used to be. The realtime lane's own
// strength is `night · (1 − edge_fade)` (`shadow_hook.wgsl::realtime_shadow`), and `night` is
// `sun_shadow_strength(celestial sun height)` — exactly ZERO once the sun is under the horizon.
// So with the old hide, an outdoor unit between dusk and dawn had NO shadow of any kind: no
// realtime cast (faded to nothing) and no oval (hidden by the cvar). The reference client draws
// the oval all night, which is what the complement below restores. The oval is NOT replaced by
// a moon-lit realtime cast: benilla's rig aims only at the celestial SUN (`shadow_sun_travel`),
// the moon direction is `pub(super)` inside `benilla-world`'s day/night module and never
// reaches a shadow lane, and the packed `night` scalar would zero a moon cast anyway.
let sun_strength = sun_shadow_strength(lighting.celestial_dir().y);
// MONKEY (moon shadows): a time jump or pending aim must restore the oval in the SAME
// frame that the receiver goes fully lit. Do not independently reconstruct the clock weight.
let lane_strength = handover.weight.abs();
// No camera yet → treat every unit as beyond the cascade, i.e. keep its oval. Failing toward
// "has a shadow" is the safe direction for a lane whose bug is a missing shadow.
let camera_pos = cam_pos.iter().next().map(GlobalTransform::translation);
@@ -331,9 +323,12 @@ fn update_shadows(
// nothing left to draw, so that stays the early-out the hard hide used to be.
let indoors = room.is_some_and(|r| r.room().is_some());
let mut lane_w = 1.0;
if video.character_shadows && !indoors {
if video.character_shadows && !indoors
&& shadow_frame.as_ref().is_some_and(|frame| frame.active && !frame.suspended)
&& ready.as_ref().is_some_and(|ready| ready.0.contains(&shadow.owner))
{
let cam_dist = camera_pos.map_or(f32::INFINITY, |c| unit.translation.distance(c));
lane_w = blob_weight(sun_strength, cam_dist, video.shadow_distance);
lane_w = blob_weight(lane_strength, cam_dist, video.shadow_distance);
if lane_w <= 1.0 / 255.0 {
hide(&mut key, &mut verts);
continue;
@@ -636,6 +631,7 @@ fn key_changed(a: &ShadowKey, b: &ShadowKey) -> bool {
/// `benilla_world::lighting::global_light` (private there; that copy is what gets packed into the
/// shader's `night` scalar). The pair is pinned by `oval_is_the_complement_of_the_realtime_lane`
/// below — if the curve is ever tuned, tune both or the handover gains a seam.
#[cfg(test)]
fn sun_shadow_strength(sun_height: f32) -> f32 {
let t = (sun_height / 0.208).clamp(0.0, 1.0); // 0.208 ≈ sin(12°)
t * t * (3.0 - 2.0 * t)
@@ -714,6 +710,45 @@ mod tests {
assert!((prev - 1.0).abs() < 1e-6);
}
/// MONKEY (moon shadows): the oval yields to the NIGHT cast by exactly what that cast lands.
///
/// The bug this forbids is the one the feature creates if the blob is left alone: a unit at
/// midnight now HAS a real silhouette shadow, and an unfaded oval under it is a second,
/// differently-shaped shadow in the same place. The complement law already handles that — what
/// has to hold is that the moon's weight enters it the same way the sun's does, and that
/// `moonShadowStrength 0` puts the full oval back, bit for bit.
///
/// The sum is exact rather than approximate because the two weights are mutually exclusive
/// (`benilla_world::lighting::moon_shadow_weight`, swept over the whole game day by its own
/// test), so `sun + moon` IS the realtime lane's total strength at every instant.
#[test]
fn the_oval_yields_to_a_moon_cast_by_exactly_its_strength() {
// Midnight, close in: the sun casts nothing, the moon casts at the shipped 0.35.
let night_sun = sun_shadow_strength(-0.3);
assert_eq!(night_sun, 0.0);
let full = blob_weight(night_sun, 10.0, 70.0);
assert_eq!(full, 1.0, "with the feature off the whole oval is still there");
let under_moon = blob_weight(night_sun + 0.35, 10.0, 70.0);
assert!(
(under_moon - 0.65).abs() < 1e-6,
"a 0.35 moon cast leaves 0.65 of the oval, not {under_moon}"
);
// …and the two together are the constant the day arm is: whatever the realtime lane lands,
// the oval lands the rest.
assert!((under_moon + 0.35 - 1.0).abs() < 1e-6);
// Past the cascade edge the moon cast resolves nothing, so the full oval returns even
// though the moon is up — the same edge behaviour the sun arm has.
assert_eq!(blob_weight(night_sun + 0.35, 80.0, 70.0), 1.0);
// The moonrise ramp reaches the oval monotonically: no pop as the weight lifts off zero.
let mut prev = 1.0;
for step in 0..=20 {
let w = blob_weight(night_sun + 0.35 * step as f32 / 20.0, 10.0, 70.0);
assert!(w <= prev + 1e-6, "the oval brightened as the moon rose, at step {step}");
prev = w;
}
assert!((prev - 0.65).abs() < 1e-6);
}
/// The box law: clamp INTO ±5 pre-scale (a cap, not a floor), then scale.
#[test]
fn box_clamp_caps_pre_scale() {
@@ -7,6 +7,7 @@
//! exists — remove either lane and the other is untouched.
use bevy::pbr::MeshMaterial3d;
use bevy::ecs::entity::EntityHashSet;
use bevy::prelude::*;
use benilla_assets::materials::WowModelMaterial;
@@ -26,10 +27,17 @@ pub(crate) struct CharacterShadowPlugin;
impl Plugin for CharacterShadowPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<CharacterLane>()
.init_resource::<CharacterShadowReady>()
.add_systems(Last, collect_character_shadows.in_set(ShadowSet::Lanes));
}
}
/// MONKEY (moon shadows): roots/ancestors with triangles in the LAST completed caster build.
/// Blobs consume this in PostUpdate, before this frame's Last rebuild: a newly uploaded proxy
/// gets a render frame before its oval yields. Cadence skips retain the same geometry/verdict.
#[derive(Resource, Default)]
pub(crate) struct CharacterShadowReady(pub EntityHashSet);
/// The character lane's retained per-frame caster.
#[derive(Resource, Default)]
struct CharacterLane {
@@ -49,10 +57,13 @@ fn collect_character_shadows(
mut demand: ResMut<ShadowDemand>,
frame: Res<ShadowFrame>,
mut lane: ResMut<CharacterLane>,
mut ready: ResMut<CharacterShadowReady>,
parents: Query<&ChildOf>,
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
parts: Query<
(
Entity,
&PickMesh,
&ModelPart,
Option<&GlobalTransform>,
@@ -71,6 +82,7 @@ fn collect_character_shadows(
// Rig down or lane off → drop our caster so it stops casting.
if !(frame.active && on) {
ready.0.clear();
if let Some(entity) = lane.caster.take() {
commands.entity(entity).despawn();
}
@@ -81,6 +93,12 @@ fn collect_character_shadows(
lane.rate.reset();
return;
}
// MONKEY (moon shadows): keep the cache but suspend CPU skin/re-upload on feature-off nights.
if frame.suspended {
ready.0.clear();
lane.rate.reset();
return;
}
let Some(material) = frame.material.clone() else {
return;
};
@@ -114,6 +132,7 @@ fn collect_character_shadows(
if let Some(handle) = lane.mesh.clone() {
if let Some(mesh) = meshes.get_mut(&handle) {
let (mut positions, mut indices) = take_mesh_buffers(mesh);
let mut built_parts = EntityHashSet::default();
let (admitted, rejected) = collect_entity_geometry(
&parts,
&rigs,
@@ -125,7 +144,14 @@ fn collect_character_shadows(
frame.tall_reach,
&mut positions,
&mut indices,
Some(&mut built_parts),
);
ready.0.clear();
for part in built_parts {
ready.0.insert(part);
// The unit root owns its blob; mounts and equipment can be nested below it.
ready.0.extend(parents.iter_ancestors(part));
}
let tris = (indices.len() / 3) as u32;
restore_mesh_buffers(mesh, positions, indices);
if shadow_trace() {
+419 -9
View File
@@ -908,6 +908,24 @@ pub(crate) const REGISTERED: &[Registered] = &[
// `gxRestart = 1` does not apply (wgpu swaps the presentation interval live, so the box takes
// effect on click), and `$WOW_NOVSYNC=1` overrides it session-only, below.
same("gxVSync", "1").latched(),
// MONKEY (advanced graphics): the one row on the Advanced Graphics page that is not a knob —
// it is a NAME for a combination of the rows under it ([`LIGHTING_PRESETS`]). Writing a preset
// name writes that preset's members; writing anything else is corrected on the next frame by
// [`lighting_quality`], which re-DERIVES the name from the members those rows actually hold.
// So the stored value is never stale: it is either a preset every member agrees with, or
// `Custom`.
//
// A STRING row, not an int, so `config.toml` and `/console set lightingQuality Medium` both
// read as what they mean — and so the ladder can gain a rung without renumbering a player's
// saved value. `Row::numeric` is false for it (the default does not parse as a number), which
// is what lets the registry accept a non-numeric write at all; `realmList` is the precedent.
ours(
"lightingQuality",
"High",
"benilla's own: the preset ladder over the dynamic light + shadow rows — Off / Low / \
Medium / High, or Custom when the members match none of them; the reference has no \
realtime light or shadow system to preset",
),
// Benilla's opt-in realtime shadow-map path, split into two INDEPENDENT lanes over one shared
// shadow rig (one sun / one map). `worldShadows` = the static world (trees, buildings, foliage)
// casts realtime shadows and baked MCSH terrain shadows switch off; `characterShadows` =
@@ -968,6 +986,17 @@ pub(crate) const REGISTERED: &[Registered] = &[
"1",
"benilla's own: multiplier on the shadow caster-collection reach, 0.25..2 (1 = unchanged)",
),
// MONKEY (moon shadows): how dark a MOON-shadowed fragment is allowed to get at night — the
// dial over the night lane's own shadow term (`benilla_world::lighting::MoonShadowStrength`,
// bridged by `dynamic_interior` beside the spell gain). `0` is the FAITHFUL null: every
// consumer guards on it, so a night at 0 renders as the build before the feature did — which
// is also what the Off and Low presets ask for.
ours(
"moonShadowStrength",
"0.35",
"benilla's own: how dark a moon-shadowed fragment gets at night, 0..1 (0 = no moon \
shadow, the reference's own night)",
),
// MONKEY (dynamic interiors): WMO interiors + their props light from the room's LIVE fixtures
// instead of the MOCV bake / the baked prop probe (`static_gx.wgsl` `interior_room_light`;
// bridged by `dynamic_interior`). The three numeric knobs are live-tunable from chat —
@@ -1087,8 +1116,8 @@ pub(crate) const REGISTERED: &[Registered] = &[
// `build_light_data`, so `SetCVar` moves the whole world on the very next frame — which is how
// "20 % / 30 % darker" gets judged at all, and `1` on either is the restore.
//
// Neither touches the fires: a point light keeps its brightness under both dials, because the
// ask is for a darker night AROUND the flame, not a dimmer flame.
// Night gain leaves fire lights alone; interior gain also scales interior fixtures,
// including their candlelight. Outdoor fires retain their brightness under both dials.
ours(
"nightGain",
"0.45",
@@ -1647,12 +1676,22 @@ impl Cvars {
/// the minimap zoom, the camera views, the remembered character, the loading screen's tip
/// cursor. Mirrored into the VM, persisted, and observed like any other write.
pub(crate) fn set(&mut self, name: &str, value: &str) -> SetOutcome {
self.write(name, value, false)
let outcome = self.write(name, value, false);
if name.eq_ignore_ascii_case("lightingQuality") {
apply_lighting_preset(self, value);
}
outcome
}
/// A write the VM's mirror already made — drained from its change queue.
pub(crate) fn set_from_vm(&mut self, name: &str, value: &str) -> SetOutcome {
self.write(name, value, true)
let outcome = self.write(name, value, true);
// Apply at the write's position in the queue: a later member edit must win,
// including a re-selection of the currently displayed preset.
if name.eq_ignore_ascii_case("lightingQuality") {
apply_lighting_preset(self, value);
}
outcome
}
/// **The table follows a value the engine already applied** — a mirror, not a write: the
@@ -1917,6 +1956,165 @@ impl Plugin for CvarPlugin {
}
}
// ─── MONKEY (advanced graphics): the lighting preset ladder ──────────────────────────────────
/// **The one place the presets are written down** — the Advanced Graphics page's `lightingQuality`
/// ladder, as (name, members) pairs in the order [`derive_lighting_quality`] tries them.
///
/// A preset is a NAME FOR A SET OF ROWS, not a knob: applying one writes each member through the
/// ordinary registry path ([`Cvars::set`]), so every observer, clamp, bridge and save that a
/// hand-typed `/console set` would run, runs. That is why this table lives beside the registry
/// rather than in the options window's Lua: the page is one of four ways to reach it (the others
/// being `/console`, the lighting debug panel, and a hand-edited `config.toml`), and a table
/// carried by the window would be a table three of them cannot see.
///
/// **A preset names only the members it decides.** `Off` says nothing about `shadowMapSize` — with
/// both shadow lanes off there is no map to size — and Low says nothing about the torch counts,
/// which its `interiorShadows 0` has already made inert. Derivation matches on exactly the members
/// a preset lists, so a row nobody's preset mentions never pushes the ladder to `Custom`.
///
/// Order matters twice: the ladder reads Off → High for a human, and the first preset whose whole
/// member list matches wins. The four are mutually exclusive on `characterShadows` /
/// `worldShadows` / `exteriorShadows`, so no value can answer to two of them — a property
/// `every_preset_derives_back_to_its_own_name` holds rather than a reader has to check.
pub(crate) const LIGHTING_PRESETS: &[(&str, &[(&str, &str)])] = &[
// Everything this whole system invented, off — the client as it rendered before any of it
// existed. Not "cheap": OFF, so a machine that cannot afford the lanes can say so in one
// click, and so a bug report can be split from a taste report in one click too.
(
"Off",
&[
("characterShadows", "0"),
("worldShadows", "0"),
("interiorLight", "0"),
("interiorShadows", "0"),
("exteriorShadows", "0"),
("spellLightGain", "0"),
("fireLightGain", "0"),
("nightGain", "1.0"),
("interiorGain", "1.0"),
("moonShadowStrength", "0"),
("fireFlicker", "0"),
],
),
// Character silhouettes and lit rooms, and nothing that costs a second shadow pass: no world
// lane, no torch maps indoors or out, no moon term, and the smallest sun map on the ladder.
(
"Low",
&[
("characterShadows", "1"),
("worldShadows", "0"),
("shadowMapSize", "1024"),
("interiorLight", "1"),
("interiorShadows", "0"),
("exteriorShadows", "0"),
("spellLightGain", "1"),
("fireLightGain", "1"),
("nightGain", "0.45"),
("interiorGain", "0.5"),
("moonShadowStrength", "0"),
("fireFlicker", "1"),
],
),
// Both sun lanes, indoor torch shadows at half the residency, and the moon term — but no
// OUTDOOR torch shadows, which are the one lane whose cost is paid in the open world where the
// sun lanes are already paying.
(
"Medium",
&[
("characterShadows", "1"),
("worldShadows", "1"),
("shadowMapSize", "2048"),
("interiorLight", "1"),
("interiorShadows", "1"),
("interiorShadowCasters", "6"),
("interiorShadowDynamic", "2"),
("exteriorShadows", "0"),
("spellLightGain", "1"),
("fireLightGain", "1"),
("nightGain", "0.45"),
("interiorGain", "0.5"),
("moonShadowStrength", "0.35"),
("fireFlicker", "1"),
],
),
// **High IS the shipped default**, member for member — which is a property, not a coincidence:
// `the_high_preset_is_the_registered_defaults` welds the two, so a fresh `benilla-config`
// reads "High" on this row rather than "Custom".
(
"High",
&[
("characterShadows", "1"),
("worldShadows", "1"),
("shadowMapSize", "2048"),
("interiorLight", "1"),
("interiorShadows", "1"),
("interiorShadowCasters", "12"),
("interiorShadowDynamic", "4"),
("exteriorShadows", "1"),
("spellLightGain", "1"),
("fireLightGain", "1"),
("nightGain", "0.45"),
("interiorGain", "0.5"),
("moonShadowStrength", "0.35"),
("fireFlicker", "1"),
],
),
];
/// What the ladder shows when the members match no preset. Not a preset: selecting it writes
/// nothing, and the next derivation puts back whatever the members really say.
pub(crate) const LIGHTING_CUSTOM: &str = "Custom";
/// Two registry values are the SAME setting when they are the same number — `"0"`, `"0.00"` and
/// `"0.0"` all mean a lane that is off, and a preset whose member reads `"1"` must not be called
/// `Custom` because a slider wrote `"1.00"`. Falls back to a case-insensitive string compare for
/// the rows that hold words.
fn same_value(a: &str, b: &str) -> bool {
match (a.trim().parse::<f32>(), b.trim().parse::<f32>()) {
(Ok(x), Ok(y)) => (x - y).abs() < 1e-4,
_ => a.eq_ignore_ascii_case(b),
}
}
/// **The name the members currently spell** — the first preset every one of whose members matches
/// the live registry, or [`LIGHTING_CUSTOM`]. This is the only thing `lightingQuality` is ever
/// allowed to say, which is what keeps the row from going stale behind a `/console` write.
pub(crate) fn derive_lighting_quality(cvars: &Cvars) -> &'static str {
for (name, members) in LIGHTING_PRESETS {
let matched = members
.iter()
.all(|(k, v)| cvars.get(k).is_some_and(|live| same_value(live, v)));
if matched {
return name;
}
}
LIGHTING_CUSTOM
}
/// Write one preset's members. Ordinary registry writes: observers, clamps and the save all run.
/// Unknown names (including `Custom`) write nothing and say so by answering `false`.
pub(crate) fn apply_lighting_preset(cvars: &mut Cvars, name: &str) -> bool {
let Some((_, members)) = LIGHTING_PRESETS
.iter()
.find(|(n, _)| n.eq_ignore_ascii_case(name))
else {
return false;
};
for (k, v) in *members {
cvars.set(k, v);
}
true
}
/// Derive after all ordered writes, before the mirror/observer flush. File loading bypasses
/// `set`, so saved members remain authoritative even when the saved preset name disagrees.
fn lighting_quality(cvars: &mut Cvars) {
let derived = derive_lighting_quality(cvars);
// A derived label is a mirror, never another request to apply a preset.
cvars.mirror("lightingQuality", derived);
}
/// **What the environment took for this session, and what it set it to** — read off the knobs
/// the env levers already seeded (`RenderScale::default()` reads `$WOW_RENDER_SCALE`, and so
/// on), so the registry answers `GetCVar` with the value the client is actually running at.
@@ -2119,6 +2317,7 @@ pub(crate) fn sync_cvars(
}
}
let Some(mut script) = script else {
lighting_quality(&mut cvars);
// Nothing to mirror into; a later VM is seeded from the table, which carries it all.
if cvars.has_outbox() {
cvars.take_outbox();
@@ -2146,6 +2345,7 @@ pub(crate) fn sync_cvars(
cvars.set_from_vm(&name, &value);
}
}
lighting_quality(&mut cvars);
if seeded.claim(&script) {
// The file's unclaimed entries go in FIRST (decision 1291): an addon's `RegisterCVar`
// later starts its key at the saved value. Then the table itself, at its live values.
@@ -2208,8 +2408,12 @@ pub(crate) fn sync_cvars(
});
}
if cvars.has_outbox() {
for (name, value) in cvars.take_outbox() {
script.set_cvar_host(&name, &value);
for (name, _) in cvars.take_outbox() {
// A preset may have queued this before a newer VM member edit. Publish
// the final registry value, never that older queued snapshot.
if let Some(value) = cvars.get(&name) {
script.set_cvar_host(&name, value);
}
}
}
if cvars.has_events() {
@@ -2249,6 +2453,7 @@ pub(crate) fn fold_dying_vm_cvars(world: &mut World) {
for (name, value) in changes {
cvars.set_from_vm(&name, &value);
}
lighting_quality(&mut cvars);
if cvars.has_outbox() {
cvars.take_outbox(); // the VM this was for is going away
}
@@ -2660,7 +2865,7 @@ mod tests {
// a row, forgot its weld" fail HERE: the length check below is the gate.
let shadows = VideoConfig::default();
let flag = |b: bool| if b { 1.0 } else { 0.0 };
let lighting: [(&str, f32); 29] = [
let lighting: [(&str, f32); 30] = [
// The two sun lanes and the cascade they share.
("worldShadows", flag(shadows.world_shadows)),
("characterShadows", flag(shadows.character_shadows)),
@@ -2671,6 +2876,8 @@ mod tests {
("characterShadowRate", shadows.character_shadow_rate as f32),
("worldShadowRate", shadows.world_shadow_rate as f32),
("shadowCasterReach", shadows.shadow_caster_reach),
// MONKEY (moon shadows): the night lane's own shadow darkness.
("moonShadowStrength", shadows.moon_shadow_strength),
// MONKEY (dynamic interiors): the room lane's switch and its light law.
("interiorLight", flag(shadows.interior_light)),
("interiorAmbient", shadows.interior_ambient),
@@ -2710,9 +2917,9 @@ mod tests {
assert_eq!(d[name], want, "{name}: registered default left the knob");
}
// …and the census IS the row set. A name here that nothing registers would weld against a
// row the client does not have; the length is the other half — 29 rows, 29 welds.
// row the client does not have; the length is the other half — 30 rows, 30 welds.
let welded: std::collections::BTreeSet<&str> = lighting.iter().map(|(n, _)| *n).collect();
assert_eq!(welded.len(), 29, "the lighting lane welds 29 distinct rows");
assert_eq!(welded.len(), 30, "the lighting lane welds 30 distinct rows");
for name in &welded {
assert!(
REGISTERED.iter().any(|r| r.name == *name),
@@ -2727,6 +2934,10 @@ mod tests {
// candle-lit surfaces moving under +10 % — only true at this number.
assert_eq!(d["interiorBakeFloor"], 0.12);
assert_eq!(d["spellLightGain"], 1.0, "the spell lane ships neutral");
// The 31st lighting row, `lightingQuality`, is deliberately NOT in that census: it is the
// only one with no `VideoConfig` knob to weld to, because it is a NAME for the rows above
// rather than a knob of its own. Its own weld is `the_high_preset_is_the_registered_
// defaults` — a fresh config must read "High", not "Custom".
// ── end MONKEY (lighting) ─────────────────────────────────────────────────────────────
// The pane half-rate (1444) welds to the portrait knob's shipped default.
assert_eq!(d["boothHalfRate"] != 0.0, PaneRate::default().half);
@@ -2736,6 +2947,205 @@ mod tests {
assert_eq!(d["renderScale"], 1.0);
}
// ── MONKEY (advanced graphics): the preset ladder ─────────────────────────────────────────
/// A registry holding nothing but the registered defaults — the state a fresh
/// `benilla-config` boots into, which is what every claim below is measured against.
fn fresh_registry() -> Cvars {
Cvars::default()
}
/// **The round trip, for every rung**: apply a preset, derive, and get that preset's own name
/// back. This is the property the whole ladder rests on — without it the dropdown would show
/// `Custom` the instant after the player picked something, which is the exact failure the
/// "derived, never stored stale" rule exists to prevent.
#[test]
fn every_preset_derives_back_to_its_own_name() {
for (from, _) in LIGHTING_PRESETS {
for (name, _) in LIGHTING_PRESETS {
let mut cvars = fresh_registry();
apply_lighting_preset(&mut cvars, from);
cvars.set("shadowDistance", "120");
cvars.set("interiorShadowSoft", "2.5");
assert!(apply_lighting_preset(&mut cvars, name), "{name}: applied");
assert_eq!(derive_lighting_quality(&cvars), *name, "{from} -> {name}");
assert_eq!(cvars.get("shadowDistance"), Some("120"));
assert_eq!(cvars.get("interiorShadowSoft"), Some("2.5"));
for member in ["fireLightGain", "nightGain", "interiorGain"] {
let expected = if *name == "Off" {
if member == "fireLightGain" { "0" } else { "1.0" }
} else {
cvars.default_of(member).unwrap()
};
assert!(same_value(cvars.get(member).unwrap(), expected), "{name}/{member}");
}
}
}
}
/// **High is the shipped default, member for member.** A fresh config must read "High" on this
/// row rather than "Custom" — otherwise the very first thing a player sees on the page is a
/// word that says their settings are a hand-made combination when they have touched nothing.
#[test]
fn the_high_preset_is_the_registered_defaults() {
let cvars = fresh_registry();
assert_eq!(derive_lighting_quality(&cvars), "High");
assert_eq!(
cvars.get("lightingQuality"),
Some("High"),
"the registered default agrees with what a fresh registry derives"
);
// …and the table is not merely CONSISTENT with the defaults, it IS them: a member whose
// registered default moved without its preset entry moving would still derive "High"
// above (both sides move together), so the weld is checked against the row's own default.
let (_, members) = LIGHTING_PRESETS
.iter()
.find(|(n, _)| *n == "High")
.expect("the High rung");
for (k, v) in *members {
assert!(
same_value(cvars.default_of(k).expect("a registered member"), v),
"{k}: the High preset says {v}, the registry's default says {:?}",
cvars.default_of(k)
);
}
}
/// **One member off the rung is `Custom`** — for every rung, and for every member of it, which
/// is the half that keeps the derivation from being a lucky match on a favourite row.
#[test]
fn one_changed_member_derives_custom() {
for (name, members) in LIGHTING_PRESETS {
for (k, v) in *members {
let mut cvars = fresh_registry();
apply_lighting_preset(&mut cvars, name);
// Somewhere else on the row's own scale: the flags flip, the numbers move by one.
let moved = match v.trim().parse::<f32>() {
Ok(x) if x == 0.0 => "1".to_string(),
Ok(x) if x == 1.0 => "0".to_string(),
Ok(x) => (x + 1.0).to_string(),
Err(_) => format!("{v}x"),
};
assert_eq!(cvars.set(k, &moved), SetOutcome::Changed, "{name}/{k}");
assert_eq!(
derive_lighting_quality(&cvars),
LIGHTING_CUSTOM,
"{name}: {k} moved to {moved} and the ladder still says {name}"
);
}
}
}
/// A value that says the same NUMBER is the same setting: the options window's sliders write
/// `"1"`, a hand-edited config may say `"1.0"`, and `%.2f` formatting elsewhere says `"1.00"`.
/// A ladder that compared strings would call every one of those `Custom`.
#[test]
fn the_ladder_compares_numbers_not_spellings() {
let mut cvars = fresh_registry();
apply_lighting_preset(&mut cvars, "High");
for spelling in ["1.0", "1.00", "1"] {
cvars.set("spellLightGain", spelling);
assert_eq!(derive_lighting_quality(&cvars), "High", "{spelling}");
}
cvars.set("moonShadowStrength", "0.350");
assert_eq!(derive_lighting_quality(&cvars), "High");
}
/// `Custom` is not a rung: picking it writes nothing, so the members — and therefore the name
/// the next derivation lands on — are exactly where they were.
#[test]
fn picking_custom_writes_nothing() {
let mut cvars = fresh_registry();
apply_lighting_preset(&mut cvars, "Medium");
assert!(!apply_lighting_preset(&mut cvars, LIGHTING_CUSTOM));
assert!(!apply_lighting_preset(&mut cvars, "Ultra"));
assert_eq!(derive_lighting_quality(&cvars), "Medium");
}
/// Every member every rung names is a REGISTERED row — a typo'd member would otherwise make
/// its whole preset underivable (a `cvars.get` that answers `None` never matches), and the
/// only symptom would be a dropdown stuck on `Custom`.
#[test]
fn every_preset_member_is_registered_and_inside_its_observers_clamp() {
let mut app = cvar_app();
for (name, members) in LIGHTING_PRESETS {
for (k, value) in *members {
assert!(
REGISTERED.iter().any(|r| r.name == *k),
"{name}: member {k} is not registered"
);
// Drive the real setter: a clamp, integer conversion or map-size snap
// must leave every preset value unchanged.
apply(&mut app, k, "-1");
apply(&mut app, k, value);
let video = res::<VideoConfig>(&app);
let applied = match *k {
"characterShadows" => video.character_shadows as u32 as f32,
"worldShadows" => video.world_shadows as u32 as f32,
"interiorLight" => video.interior_light as u32 as f32,
"interiorShadows" => video.interior_shadows as u32 as f32,
"exteriorShadows" => video.exterior_shadows as u32 as f32,
"shadowMapSize" => video.shadow_map_size as f32,
"interiorShadowCasters" => video.interior_shadow_casters as f32,
"interiorShadowDynamic" => video.interior_shadow_dynamic as f32,
"spellLightGain" => video.spell_light_gain,
"fireLightGain" => video.fire_light_gain,
"moonShadowStrength" => video.moon_shadow_strength,
"fireFlicker" => video.fire_flicker,
"nightGain" => video.night_gain,
"interiorGain" => video.interior_gain,
_ => panic!("add the observer readback for {k}"),
};
assert_eq!(applied, value.parse::<f32>().unwrap(), "{name}/{k}");
}
}
}
#[test]
fn preset_then_member_edit_survives_sync_observers_and_later_flushes() {
for seeded in [false, true] {
for flush_preset_first in [false, true] {
let mut app = cvar_app();
app.world_mut().non_send_resource_mut::<UiScript>()
.register_cvars(registered_pairs());
// Run the production Update schedule without Startup's on-disk config load.
if seeded {
app.world_mut().run_schedule(Update);
}
app.world_mut().non_send_resource_mut::<UiScript>()
.run("SetCVar('lightingQuality', 'Low')").unwrap();
if flush_preset_first {
app.world_mut().run_schedule(Update);
assert_eq!(res::<VideoConfig>(&app).shadow_map_size, 1024);
}
app.world_mut().non_send_resource_mut::<UiScript>()
.run("SetCVar('shadowMapSize', '4096')").unwrap();
for _ in 0..3 {
app.world_mut().run_schedule(Update);
let cvars = res::<Cvars>(&app);
assert_eq!(cvars.get("shadowMapSize"), Some("4096"));
assert_eq!(cvars.get("lightingQuality"), Some(LIGHTING_CUSTOM));
assert_eq!(res::<VideoConfig>(&app).shadow_map_size, 4096);
let script = app.world().non_send_resource::<UiScript>();
assert_eq!(script.cvar("shadowMapSize").as_deref(), Some("4096"));
assert_eq!(script.cvar("lightingQuality").as_deref(), Some(LIGHTING_CUSTOM));
}
}
}
}
#[test]
fn fire_and_spell_gains_above_two_reach_the_video_observer() {
let mut app = cvar_app();
for value in ["3.25", "4"] {
apply(&mut app, "fireLightGain", value);
apply(&mut app, "spellLightGain", value);
let expected = value.parse::<f32>().unwrap();
assert_eq!(res::<VideoConfig>(&app).fire_light_gain, expected);
assert_eq!(res::<VideoConfig>(&app).spell_light_gain, expected);
}
}
/// **Every arm, through the registry and its observers.** The old central `apply_to_knobs`
/// match is gone (2303); each arm lives beside the knob it writes, and this drives the whole
/// set through a real `App` — a host write, the observers firing synchronously — so a knob
@@ -43,6 +43,7 @@ macro_rules! knob {
use Widget::{Choice, Integer, Slider};
// This developer-only panel intentionally keeps its labels and help text in English.
const INTERIOR: &[Knob] = &[
knob!(flag "interiorLight", interior_light, "Fixture-lit interiors; off restores the baked path."),
knob!("interiorGain", interior_gain, Slider(0.2..=1.5), "Overall room input brightness."),
@@ -89,6 +90,10 @@ const GROUPS: &[(&str, &[Knob])] = &[
]),
("Night", &[
knob!("nightGain", night_gain, Slider(0.2..=1.5), "Exterior night brightness; inert by day."),
// MONKEY (moon shadows): in "Night" rather than beside the sun dials, because that is
// where both its cost and its taste question live — it is inert by day, and what it buys
// is "the world is not flat after dark" for a second shadowed directional term.
knob!("moonShadowStrength", moon_shadow_strength, Slider(0.0..=1.0), "How dark a moon-shadowed fragment gets at night; 0 = no moon shadow."),
]),
("Debug", &[
knob!("interiorDebug", interior_debug, Choice(&[0, 1, 2, 3, 4]), "0 off, 1 classification, 2 shadow, 3 caster count, 4 WMO lane map."),
@@ -147,8 +152,29 @@ pub(super) fn section(ui: &mut egui::Ui, video: &VideoConfig, script: Option<&mu
ui.weak("Lighting controls are available once the UI session is ready.");
return;
};
// MONKEY (advanced graphics): the `lightingQuality` ladder — the same row the player reaches
// on the Advanced Graphics page, so this panel and that window cannot show different answers.
// The rungs are `cvars::LIGHTING_PRESETS`, never a second table here; the CURRENT name is read
// off the VM mirror rather than derived locally, because the registry's own per-frame
// derivation is the one authority on it and a second derivation would be a second opinion.
ui.horizontal(|ui| {
ui.label("Presets");
ui.label("Lighting quality")
.on_hover_text("Writes the member rows below; reads back Custom when they match no rung.");
let current = script.cvar("lightingQuality").unwrap_or_default();
for (name, _) in crate::cvars::LIGHTING_PRESETS {
if ui
.selectable_label(current.eq_ignore_ascii_case(name), *name)
.clicked()
{
script.set_cvar_engine("lightingQuality", name);
}
}
if current.eq_ignore_ascii_case(crate::cvars::LIGHTING_CUSTOM) {
ui.weak(crate::cvars::LIGHTING_CUSTOM);
}
});
ui.horizontal(|ui| {
ui.label("Interior presets");
for (label, values) in [
// MONKEY (bake floor): the fourth value is `interiorBakeFloor`, and it rides the
// presets because it is a room BRIGHTNESS INPUT exactly like the three beside it.
+13 -1
View File
@@ -16,7 +16,13 @@
//!
//! MONKEY (spellLightGain): and `spellLightGain` → [`benilla_world::lighting::SpellLightGain`],
//! the third of the same shape.
use benilla_world::lighting::{DynamicInteriors, FireLightGain, SpellLightGain};
//!
//! MONKEY (moon shadows): and `moonShadowStrength` →
//! [`benilla_world::lighting::MoonShadowStrength`], the fourth. Same shape, same guard, same
//! liveness requirement — and `0` is that lane's faithful null the way `fireLightGain 0` is the
//! synthesised-fire lane's kill switch. It is what the Advanced Graphics page's Moon Shadows row
//! and the Off/Low presets reach.
use benilla_world::lighting::{DynamicInteriors, FireLightGain, MoonShadowStrength, SpellLightGain};
use bevy::prelude::*;
use crate::video::VideoConfig;
@@ -46,6 +52,9 @@ fn bridge(
// an interior knob at all: a fireball down a street takes it exactly as one down a corridor
// does, and `FireLightGain` beside it already stands as the precedent for that.
mut spell: ResMut<SpellLightGain>,
// MONKEY (moon shadows): the night lane's shadow darkness, on the same bridge and for the same
// reasons as the two gains above it — one live `f32`, one benilla-world resource, one guard.
mut moon: ResMut<MoonShadowStrength>,
) {
if fire.0 != video.fire_light_gain {
fire.0 = video.fire_light_gain;
@@ -53,6 +62,9 @@ fn bridge(
if spell.0 != video.spell_light_gain {
spell.0 = video.spell_light_gain;
}
if moon.0 != video.moon_shadow_strength {
moon.0 = video.moon_shadow_strength;
}
let want = DynamicInteriors {
enabled: video.interior_light,
ambient: video.interior_ambient,
+3
View File
@@ -51,6 +51,9 @@ use carried_light::spawn_carried_lights;
// firework throws, hung on the effect's own root so the effect's death reaps it. Used by the kit,
// missile and dest-anchored lanes.
use carried_light::spawn_spell_light;
// MONKEY (area spell light): and the same file's PERSISTENT GROUND spawner — one light per
// DynamicObject area effect, hung on the anchor so the server's destroy is what ends it.
use carried_light::spawn_area_spell_light;
// MONKEY (carried light stability): `torch_shadow` needs the settle verdict to refuse a MOVING
// carried light a cube-shadow slot.
pub(crate) use carried_light::CarriedLightMotion;
@@ -38,7 +38,11 @@ use benilla_world::wmo_portal::{WmoGroupVis, WmoPortalInstance, WmoRoom};
// MONKEY (spell light): the effect-side lifecycle this file's spawn helper stamps on — the
// envelope, the budget and the kill switch all live beside the rest of the effect lifecycle
// (`spell_fx::lifecycle`), because that is what a spell light's lifetime IS.
use super::spell_fx::{spell_lights_enabled, SpellLight, SpellLightMode, SPELL_BURST_SPAN};
// MONKEY (area spell light): `AreaSpellLight` too — the persistent ground lane's marker, stamped
// here beside the rest of a spell light's tags.
use super::spell_fx::{
spell_lights_enabled, AreaSpellLight, SpellLight, SpellLightMode, SPELL_BURST_SPAN,
};
/// Spawn a `PointLight` child for each **casting** (`type==1`, not visibility-gated dark) M2 light of
/// an entity's model.
@@ -127,8 +131,12 @@ pub(super) fn spawn_carried_lights(
commands,
parent,
local,
l,
fx,
SpellLightSource {
color: l.def.diffuse_color,
intensity: l.def.diffuse_intensity,
kind: fx.kind,
onset: fx.onset,
},
// A GameObject that carries a spell light IS a one-shot: a firework shell, a
// trigger's effect model. Nothing on this lane holds.
SpellLightMode::Burst {
@@ -210,7 +218,125 @@ pub(super) fn spawn_spell_light(
let Some((l, fx)) = lights.iter().find_map(|l| l.spell.map(|fx| (l, fx))) else {
return false;
};
spawn_spell_light_child(commands, root, l.def.position, l, fx, mode).is_some()
spawn_spell_light_child(
commands,
root,
l.def.position,
SpellLightSource {
color: l.def.diffuse_color,
intensity: l.def.diffuse_intensity,
kind: fx.kind,
onset: fx.onset,
},
mode,
)
.is_some()
}
/// MONKEY (area spell light): the ONE light a persistent ground effect throws, hung on the
/// DynamicObject anchor itself so it lives and dies with the area object.
///
/// `kind` is the area lane's own verdict ([`benilla_formats::area_light_kind`] — the model path,
/// then the spell's school, then the model's hue), and it is handed in rather than read back off
/// the model because **the model routinely has nothing to read**: Flamestrike's burning patch and
/// Explosive Trap's are flat animated decals with zero particle emitters, so
/// `synthesize_spell_light` returns `None` for exactly the effects this feature is about. Where the
/// model DOES carry a synthesised light we take its colour, intensity and onset (the artist's own
/// ramp beats any default); where it does not we light it from the school's hue at the area
/// fallback rung.
///
/// The light hangs [`AREA_LIGHT_LIFT`](benilla_formats::AREA_LIGHT_LIFT) above the emitter's own
/// point — a dynobj sits ON the ground, and the faithful `1/(0.7d + 0.03d²)` falloff at `d → 0`
/// would blow out the terrain under the centre while the rim of the same patch got nothing.
///
/// `radius` is the wire `DYNAMICOBJECT_RADIUS`: it sizes the interior pool
/// ([`benilla_formats::area_reach`], not the shared intensity bucket — an area effect's footprint
/// is stated on the wire and guessing it from particle size would be a worse answer than the one we
/// were handed) and it is the impact dedupe's test radius.
pub(super) fn spawn_area_spell_light(
commands: &mut Commands,
lights: &[benilla_assets::ModelLight],
kind: benilla_formats::SpellLightKind,
anchor: Entity,
radius: f32,
) -> bool {
if !kind.lights() {
return false;
}
// The model's own synthesised light, if it has one — its `SpellLightInfo` is deliberately NOT
// read: the school is the area lane's (handed in above, school column included) and the onset
// is a fact about an emitter's FIRST pass, i.e. a fuse, on an object that loops until the
// server destroys it. The ground catches when the object appears.
let synth = lights.iter().find(|l| l.spell.is_some());
let (local, source) = match synth {
Some(l) => (
l.def.position,
SpellLightSource {
color: benilla_formats::area_color(kind, Some(l.def.diffuse_color)),
intensity: l.def.diffuse_intensity,
kind,
onset: 0.0,
},
),
// The decal-only half: no emitter to read, so the school's own hue at the area rung, hung
// over the object's origin (its model has no emitter position either).
None => (
[0.0, 0.0, 0.0],
SpellLightSource {
color: benilla_formats::area_color(kind, None),
intensity: benilla_formats::AREA_FALLBACK_INTENSITY,
kind,
onset: 0.0,
},
),
};
let lifted = [
local[0],
local[1],
local[2] + benilla_formats::AREA_LIGHT_LIFT,
];
// The breathing phase: seeded from the anchor's bits so two overlapping patches swell out of
// step, and stable for the light's whole life (it is baked into the mode, never re-rolled).
let phase = (anchor.to_bits() % 1000) as f32 * 0.006_283_2;
let Some(glow) = spawn_spell_light_child(
commands,
anchor,
lifted,
source,
SpellLightMode::Area { phase },
) else {
return false;
};
let reach = benilla_formats::area_reach(radius);
commands.entity(glow).insert((
AreaSpellLight { radius },
// The pool's own size, stated rather than bucketed — see [`benilla_formats::area_reach`].
benilla_world::lighting::LightReach(reach),
));
if benilla_assets::trace::enabled() {
// Beside the shared `spell light` line the helper already wrote: the two numbers only an
// area light has, so "did the patch light, and how big was its pool" is answered from the
// trace instead of by eye.
benilla_assets::trace::line(
"fx",
&format!("area light e={glow} radius={radius:.1} reach={reach:.1}"),
);
}
true
}
/// What one spell light is made of, as its spawn site knows it — the synthesised model light's
/// numbers, or (MONKEY (area spell light)) the area lane's school-derived stand-in for a model that
/// carries none.
struct SpellLightSource {
/// Linear RGB, hue preserved.
color: [f32; 3],
/// The authored `diffuse_intensity` the [`point_light`] recipe scales.
intensity: f32,
/// The school, for the trace and any future per-school gain.
kind: benilla_formats::SpellLightKind,
/// Seconds from the instance's birth before the light comes up (a firework's fuse).
onset: f32,
}
/// The shared body of the two spawn sites (the effect lanes' [`spawn_spell_light`] and the
@@ -244,14 +370,13 @@ fn spawn_spell_light_child(
commands: &mut Commands,
parent: Entity,
local: [f32; 3],
l: &benilla_assets::ModelLight,
fx: benilla_assets::SpellLightInfo,
fx: SpellLightSource,
mode: SpellLightMode,
) -> Option<Entity> {
if !spell_lights_enabled() {
return None;
}
let lit = point_light(l.def.diffuse_color, l.def.diffuse_intensity);
let lit = point_light(fx.color, fx.intensity);
// Born DARK and raised by the envelope: the onset is real time (a firework's fuse), and a
// light that showed its full strength on its first frame and only then ramped would flash
// once before every effect it belongs to.
+119 -13
View File
@@ -101,6 +101,31 @@ impl GroundFx {
}
}
/// MONKEY (area spell light): what an armed DynamicObject anchor knows about the light its area
/// visual should throw, stamped at arm time and read once the model finishes building.
///
/// It exists because the two halves of the answer arrive at different moments, in different
/// systems. `radius` (the wire AoE) and `school` (the spell's `Spell.dbc` column) are only in hand
/// at [`arm_ground_effects`], where the create's fields and the spell catalog are; the model's own
/// verdict — whether its emitters synthesised a light, and in what hue — is only in hand at
/// [`attach_ground_fx_models`], however many frames later the M2 finishes building. Carrying the
/// first pair forward on the anchor is cheaper and far less fragile than re-resolving the spell id
/// there.
///
/// Its PRESENCE is load-bearing too: it marks the instance as a dynobj AREA visual, so the looping
/// branch of the attach takes the area verdict *including its refusals*. Without that a frost
/// area's visual A would fall through to the impact-flash light this lane used to spawn for it, and
/// Blizzard would light after all.
#[derive(Component)]
pub(super) struct AreaLightPlan {
/// `DYNAMICOBJECT_RADIUS` — the effect's real footprint (the shard emitter spreads over exactly
/// it), which sizes the pool and is the impact dedupe's test radius.
radius: f32,
/// The spell's `Spell.dbc` **School** index — the locale-proof half of the classification
/// ([`benilla_formats::area_light_kind`]).
school: u32,
}
/// The shard emitter riding a DynamicObject anchor (visual B — the ref's `AUBlizzardObject`).
/// Dies with the anchor (emission stops at destroy); its spawned shards are free entities and
/// finish on their own — the ref's emitter tail.
@@ -164,6 +189,14 @@ pub(super) fn arm_ground_effects(
.dynamicobject_position()
.map(|(_, f)| f)
.unwrap_or(0.0);
// MONKEY (area spell light): stamp the two facts only this system holds, for the attach to
// finish the verdict with. Unconditional — a plan whose school ends up dark is exactly how
// Blizzard's visual A is told to throw NO light (see [`AreaLightPlan`]).
let radius = store.0.dynamicobject_radius().unwrap_or(0.0);
commands.entity(anchor).insert(AreaLightPlan {
radius,
school: spells.catalog.get(spell_id).map_or(0, |d| d.school),
});
// Visual A — the object's own model, gated on field 11, Z-rotated by FACING. A child of
// the anchor: the destroy pop takes it exactly like the ref's scene-node teardown.
if stages.area_gate != 0 && stages.area_effect != 0 {
@@ -189,7 +222,7 @@ pub(super) fn arm_ground_effects(
ensure_model(&mut fx, &asset_server, &path);
commands.entity(anchor).insert(ShardEmitter {
path,
radius: store.0.dynamicobject_radius().unwrap_or(0.0),
radius,
rate: proc.params[1],
accum: 0.0,
rng: 0x9e3779b97f4a7c15 ^ anchor.to_bits(),
@@ -264,13 +297,49 @@ pub(super) fn tick_shard_emitters(
}
}
/// MONKEY (area spell light): how far ABOVE OR BELOW a live area pool's own height an impact may
/// still be deduped against it (yd).
///
/// The footprint test itself is HORIZONTAL, because that is what `DYNAMICOBJECT_RADIUS` describes —
/// a disc of ground. A vertical window is needed anyway so the disc does not become an infinite
/// column: a Rain of Fire on the bridge above must not silence the impacts under it. Generous
/// enough to cover the lift the pool hangs at plus a shard landing on a slope, tight enough to be a
/// storey.
const AREA_DEDUPE_HEIGHT: f32 = 10.0;
/// Is `at` (Bevy world space) inside a live area pool's footprint? See [`AREA_DEDUPE_HEIGHT`].
fn inside_a_live_area(
at: Vec3,
areas: &Query<(&GlobalTransform, &super::spell_fx::AreaSpellLight)>,
) -> bool {
areas.iter().any(|(gt, area)| {
let d = at - gt.translation();
d.y.abs() <= AREA_DEDUPE_HEIGHT && d.x * d.x + d.z * d.z <= area.radius * area.radius
})
}
/// Attach model parts to pending instances whose M2 finished building (the missile pattern —
/// free world models, ground-anchored so authored flat quads decal to the terrain), start the
/// one-shot clocks, and run both reapers (one-shot expiry; the loop-repeat override).
#[allow(clippy::type_complexity)]
pub(super) fn attach_ground_fx_models(
mut commands: Commands,
time: Res<Time>,
mut instances: Query<(Entity, &mut GroundFx, Option<&mut AnimationPlayer>)>,
// MONKEY (area spell light): `ChildOf` joins a VISUAL A instance to the DynamicObject anchor
// that owns it — where the plan is, and the entity the light must hang on so it dies with the
// area object. `Transform` is the instance's world point for a FREE one (a shard, a dest
// one-shot), which is the dedupe's input.
mut instances: Query<(
Entity,
&mut GroundFx,
Option<&mut AnimationPlayer>,
Option<&ChildOf>,
&Transform,
)>,
// MONKEY (area spell light): the arm-time half of the area verdict, read through the anchor.
plans: Query<&AreaLightPlan>,
// MONKEY (area spell light): every live area pool, for the impact dedupe.
areas: Query<(&GlobalTransform, &super::spell_fx::AreaSpellLight)>,
fx: Option<ResMut<SpellFx>>,
asset_server: Res<AssetServer>,
mut wow_materials: ResMut<Assets<benilla_assets::materials::WowModelMaterial>>,
@@ -282,7 +351,7 @@ pub(super) fn attach_ground_fx_models(
) {
let Some(mut fx) = fx else { return };
let now = time.elapsed_secs();
for (entity, mut inst, player) in &mut instances {
for (entity, mut inst, player, parent, at) in &mut instances {
if !inst.spawned {
ensure_model(&mut fx, &asset_server, &inst.path);
let Some(dm) = fx.models.get(&inst.path) else {
@@ -313,22 +382,59 @@ pub(super) fn attach_ground_fx_models(
continue; // model still building — attach on a later pass
}
inst.spawned = true;
// MONKEY (area spell light): a VISUAL A on an armed anchor is a persistent ground
// effect, and takes the AREA lane instead of the flash below — one light for the
// DynamicObject's whole life, hung on the ANCHOR (not on this instance) so the
// server's destroy is what ends it. `area_light_kind` finishes the verdict the plan
// started, and its refusals are honoured: when it says dark, this instance gets no
// light at all rather than falling through to the flash.
let plan = parent.map(|c| c.parent()).and_then(|anchor| {
plans
.get(anchor)
.ok()
.map(|plan| (anchor, plan))
.filter(|_| inst.looping)
});
if let Some((anchor, plan)) = plan {
let kind = benilla_formats::area_light_kind(
&inst.path,
plan.school,
dm.lights.iter().find_map(|l| l.spell.map(|fx| fx.kind)),
);
super::spawn_area_spell_light(
&mut commands,
&dm.lights,
kind,
anchor,
plan.radius,
);
continue; // no one-shot clock and no flash — the area object owns both ends
}
// MONKEY (spell light): the IMPACT flash. A dest-anchored effect is the one lane whose
// light must not hold — a Fire Nova lights the ground it lands on and is gone. The
// burst span is the model's own first-sequence duration where it authors one (so the
// light fades with the effect rather than on a guess), the shared default otherwise;
// a LOOPING plant (a persistent ground aura) takes the default too, because "as long
// as the aura lasts" is exactly what a burst must not be.
let span = dm
.first_seq_span
.filter(|_| !inst.looping)
.unwrap_or(super::spell_fx::SPELL_BURST_SPAN);
super::spawn_spell_light(
&mut commands,
&dm.lights,
entity,
super::spell_fx::SpellLightMode::Burst { span },
);
//
// MONKEY (area spell light): …unless it lands INSIDE a live area pool, in which case it
// throws nothing. Rain of Fire emits a shard 5× a second from this very model, each of
// which used to spawn its own 0.6 s flash: 5 lights/s stacking on one patch of ground,
// strobing, and — before the budget's area exemption — evicting the standing pool they
// were landing in. The area light already IS the light of that ground; a second one per
// impact adds nothing but the flicker.
if !inside_a_live_area(at.translation, &areas) {
let span = dm
.first_seq_span
.filter(|_| !inst.looping)
.unwrap_or(super::spell_fx::SPELL_BURST_SPAN);
super::spawn_spell_light(
&mut commands,
&dm.lights,
entity,
super::spell_fx::SpellLightMode::Burst { span },
);
}
if !inst.looping {
// One pass of the first sequence — the kit pipeline's completion-callback
// stand-in (`spell_fx`'s span clock, same law).
@@ -250,6 +250,31 @@ pub(crate) const SPELL_BURST_SPAN: f32 = 0.6;
/// light that outlived the visual by a second would read as a stuck effect.
pub(crate) const SPELL_REAP_FADE: f32 = 0.25;
/// MONKEY (area spell light): the AREA mode's ramp — longer than [`SPELL_LIGHT_RAMP`] on purpose.
/// The 0.1 s ramp exists to keep a FLASH from appearing on one frame; a ground effect is a thing
/// that CATCHES, and a patch of terrain that reaches full brightness in a tenth of a second reads
/// as a light being switched on rather than as a fire spreading over it.
pub(crate) const SPELL_AREA_RAMP: f32 = 0.25;
/// MONKEY (area spell light): how fast an area light goes dark once the DynamicObject it belongs to
/// is destroyed. Slower than the kit's [`SPELL_REAP_FADE`] because the thing ending is bigger — a
/// 24 yd pool snapping off in a quarter second is a visible pop across a whole clearing — and still
/// far inside the ~2 s the anchor's own teardown fade takes, so the light is long gone before the
/// burning decal it lit has finished fading out.
pub(crate) const SPELL_AREA_FADE: f32 = 0.4;
/// MONKEY (area spell light): the area light's BREATHING — depth (fraction of base) and rate (Hz).
///
/// Explicitly NOT [`FlameFlicker`](benilla_world::lighting::FlameFlicker), which is the fire lane's
/// fast noisy wobble: at 8 % and 0.7 Hz this is a slow swell, under the threshold where the eye
/// reads it as the light *changing* rather than as the fire on the ground being alive. Something is
/// needed, though — an 8-second Flamestrike patch whose light is a perfectly constant disc is the
/// one thing that gives the whole effect away as a light source, and the model under it is
/// animating the entire time.
pub(crate) const SPELL_AREA_BREATH_DEPTH: f32 = 0.08;
/// See [`SPELL_AREA_BREATH_DEPTH`].
pub(crate) const SPELL_AREA_BREATH_HZ: f32 = 0.7;
/// How many spell lights may be alive at once, across every lane. The shared point-light table is
/// 512 rows packed from the 256 nearest sources every frame, and the whole feature is worth
/// nothing if a raid's worth of casts can evict a city's torches: 24 is a generous ceiling for
@@ -288,6 +313,53 @@ pub(crate) enum SpellLightMode {
/// An impact / destination effect / firework shell: full at onset, gone `span` seconds later.
/// The one shape whose *whole point* is that it does not persist.
Burst { span: f32 },
/// MONKEY (area spell light): a PERSISTENT GROUND EFFECT — one light for a whole
/// DynamicObject's life (Flamestrike's burning patch, Rain of Fire's storm, Consecration's
/// ground, a hunter's Flare). Up on the slower [`SPELL_AREA_RAMP`], held with a slow
/// [`SPELL_AREA_BREATH_DEPTH`] swell for as long as the area object stands, out over
/// [`SPELL_AREA_FADE`] when the server destroys it.
///
/// It is a mode of its own rather than a [`Self::Kit`] with a different fade because the two
/// differ in every part: the ending is driven by a `DespawnFade` on a net entity instead of an
/// `FxDecay` on an effect instance, the hold is modulated, and the ceiling treats it as the one
/// spell light that must NOT be evicted (see [`budget_spell_lights`]) — a strobe of impact
/// flashes must never be able to push the standing pool out of the table.
///
/// `phase` staggers the breathing per light (seeded from the anchor), so two overlapping
/// patches swell out of step rather than pulsing the clearing in unison.
Area { phase: f32 },
}
impl SpellLightMode {
/// Seconds this mode takes to come up.
fn ramp(self) -> f32 {
match self {
Self::Area { .. } => SPELL_AREA_RAMP,
_ => SPELL_LIGHT_RAMP,
}
}
/// Seconds this mode takes to go dark once its host has been reaped.
fn reap_fade(self) -> f32 {
match self {
Self::Area { .. } => SPELL_AREA_FADE,
_ => SPELL_REAP_FADE,
}
}
}
/// MONKEY (area spell light): the marker on a live area light, carrying the AoE footprint
/// (`DYNAMICOBJECT_RADIUS`, yd) the wire gave it.
///
/// Two readers, and neither wants it on [`SpellLight`] itself:
/// - the DEDUPE ([`super::super::dest_fx`]) — a repeating impact that lands inside a live area
/// light's footprint spawns no light of its own, which is the whole of why Rain of Fire is one
/// steady pool instead of five flashes a second;
/// - the BUDGET below, which evicts area lights last.
#[derive(Component)]
pub(crate) struct AreaSpellLight {
/// The wire AoE radius (yd), horizontal — the dedupe's own test radius.
pub(crate) radius: f32,
}
/// MONKEY (spell light): one live spell light. A child of the effect root it belongs to, so the
@@ -331,18 +403,33 @@ impl SpellLight {
if t <= 0.0 {
return 0.0; // the flame this light stands for does not exist yet
}
let up = (t / SPELL_LIGHT_RAMP).min(1.0);
let ramp = self.mode.ramp();
let up = (t / ramp).min(1.0);
let down = match self.mode {
// The fade starts where the ramp ended, so a burst's peak is a real (if brief) plateau
// rather than a single-frame spike that a low frame rate could skip entirely.
SpellLightMode::Burst { span } => {
(1.0 - (t - SPELL_LIGHT_RAMP).max(0.0) / span.max(1e-3)).clamp(0.0, 1.0)
(1.0 - (t - ramp).max(0.0) / span.max(1e-3)).clamp(0.0, 1.0)
}
// MONKEY (area spell light): a hold that BREATHES. Deliberately allowed above 1.0 —
// the swell is centred on the calibrated base, so clamping the top half would turn a
// symmetric ±8 % into a one-sided dimming and leave every area light reading 4 %
// darker than the rung it was filed on.
SpellLightMode::Area { phase } => {
1.0 + SPELL_AREA_BREATH_DEPTH
* (std::f32::consts::TAU * SPELL_AREA_BREATH_HZ * t + phase).sin()
}
SpellLightMode::Kit | SpellLightMode::Missile => 1.0,
};
let reap = (1.0 - self.reaped / SPELL_REAP_FADE).clamp(0.0, 1.0);
let reap = (1.0 - self.reaped / self.mode.reap_fade()).clamp(0.0, 1.0);
up * down * reap
}
/// MONKEY (area spell light): has this light finished going dark after its host was reaped?
/// Only an AREA light asks — see the despawn note in [`advance_spell_lights`].
fn reap_complete(&self) -> bool {
self.reaped >= self.mode.reap_fade()
}
}
/// MONKEY (spell light): run every live spell light's envelope.
@@ -357,17 +444,40 @@ impl SpellLight {
/// ([`FxDecay`] on the effect root, written by the reap) must take its light down with it, and the
/// light's parent IS that root.
pub(crate) fn advance_spell_lights(
mut commands: Commands,
time: Res<Time>,
mut lights: Query<(&mut SpellLight, &mut WorldPointLight, Option<&ChildOf>)>,
reaped: Query<Has<FxDecay>>,
mut lights: Query<(
Entity,
&mut SpellLight,
&mut WorldPointLight,
Option<&ChildOf>,
)>,
// MONKEY (area spell light): the reap signal now has TWO sources, one per persistent mode.
// `FxDecay` is written on an effect INSTANCE by the kit lane's own reap; `DespawnFade` is
// written on a NET ENTITY by `SMSG_DESTROY_OBJECT` (`net::apply::objects`), which is the only
// notice a DynamicObject's end ever gives. That the anchor fades rather than popping is what
// makes an area fade possible at all: the child light outlives the server's destroy by the
// ~2 s the anchor's geometry teardown takes, and needs only 0.4 of them.
reaped: Query<(Has<FxDecay>, Has<benilla_world::model_fade::DespawnFade>)>,
) {
let dt = time.delta_secs();
for (mut light, mut point, parent) in &mut lights {
for (entity, mut light, mut point, parent) in &mut lights {
light.age += dt;
if light.mode == SpellLightMode::Kit
&& parent.is_some_and(|c| reaped.get(c.parent()).unwrap_or(false))
{
let host = parent.and_then(|c| reaped.get(c.parent()).ok());
let ended = match light.mode {
SpellLightMode::Kit => host.is_some_and(|(decay, _)| decay),
SpellLightMode::Area { .. } => host.is_some_and(|(_, fade)| fade),
SpellLightMode::Missile | SpellLightMode::Burst { .. } => false,
};
if ended {
light.reaped += dt;
// An area light's anchor lingers for its own teardown fade, several times longer than
// the light's. Give the slot back the moment the pool is dark rather than holding one
// of the 24 for a light nobody can see — the kit lane has no such gap, because its
// instance despawns on the heels of its `FxDecay`.
if matches!(light.mode, SpellLightMode::Area { .. }) && light.reap_complete() {
commands.entity(entity).try_despawn();
}
}
let want = light.base * light.envelope();
// Write only on a real change: a held kit light sits at its plateau for the whole aura,
@@ -389,14 +499,23 @@ pub(crate) fn advance_spell_lights(
///
/// Despawning the light alone never disturbs its effect: the light is a leaf child of the effect
/// root and nothing reads back from it.
pub(crate) fn budget_spell_lights(mut commands: Commands, lights: Query<(Entity, &SpellLight)>) {
pub(crate) fn budget_spell_lights(
mut commands: Commands,
lights: Query<(Entity, &SpellLight, Has<AreaSpellLight>)>,
) {
let live = lights.iter().count();
if live <= SPELL_LIGHTS_MAX {
return;
}
let mut by_age: Vec<(Entity, f32)> = lights.iter().map(|(e, s)| (e, s.age)).collect();
by_age.sort_by(|a, b| b.1.total_cmp(&a.1)); // oldest first
for (light, _) in by_age.into_iter().take(live - SPELL_LIGHTS_MAX) {
let mut order: Vec<(Entity, bool, f32)> =
lights.iter().map(|(e, s, area)| (e, area, s.age)).collect();
// MONKEY (area spell light): AREA lights go LAST, whatever their age — and an area light is
// always among the oldest, because it is the only spell light that stands for seconds. Under
// pure age ordering a Rain of Fire's own impact flashes would evict the very pool they are
// landing in, i.e. the ceiling would delete the steady light and keep the strobe. Within each
// class the rule is unchanged: oldest first, for the reasons above.
order.sort_by(|a, b| a.1.cmp(&b.1).then(b.2.total_cmp(&a.2)));
for (light, _, _) in order.into_iter().take(live - SPELL_LIGHTS_MAX) {
commands.entity(light).try_despawn();
}
}
@@ -534,6 +653,68 @@ mod tests {
assert_eq!(reaped.envelope(), 0.0);
}
/// MONKEY (area spell light) — GOLDEN: the AREA envelope's four legs, which differ from every
/// other mode's in all four. The slower ramp, the BREATHING hold (the leg that exists so an
/// 8-second Flamestrike patch is not a frozen disc), and the slower removal fade that the
/// anchor's own ~2 s teardown leaves room for.
#[test]
fn an_area_light_ramps_slowly_breathes_and_fades_out() {
let at = |mut l: SpellLight, age: f32| {
l.age = age;
l.envelope()
};
let area = || SpellLight::new(100.0, 0.0, SpellLightMode::Area { phase: 0.0 });
// The swell is a factor on the WHOLE envelope, ramp included — it is what the light is
// doing, not a decoration on the hold — so the expected values carry it.
let swell = |t: f32| {
1.0 + SPELL_AREA_BREATH_DEPTH
* (std::f32::consts::TAU * SPELL_AREA_BREATH_HZ * t).sin()
};
// The ramp is the AREA one — at the flash ramp's 0.1 s an area light is well short of up.
assert!(at(area(), SPELL_LIGHT_RAMP) < 0.5, "not a flash");
let half = SPELL_AREA_RAMP * 0.5;
assert!((at(area(), half) - 0.5 * swell(half)).abs() < 1e-3);
assert!((at(area(), SPELL_AREA_RAMP) - swell(SPELL_AREA_RAMP)).abs() < 1e-3);
// The hold BREATHES: a full period later it is back at its base, a quarter period past
// that it is at the top of the swell, and it never wanders outside ±depth. (Every mark
// below is past the ramp, so `up` is 1 and the swell is the whole of the value.)
let period = 1.0 / SPELL_AREA_BREATH_HZ;
assert!((at(area(), period) - 1.0).abs() < 1e-3, "one period, back to base");
assert!(
(at(area(), period * 1.25) - (1.0 + SPELL_AREA_BREATH_DEPTH)).abs() < 1e-3,
"the swell is centred on the base, not clamped below it"
);
assert!(
(at(area(), period * 1.75) - (1.0 - SPELL_AREA_BREATH_DEPTH)).abs() < 1e-3
);
for step in 0..200 {
let v = at(area(), 1.0 + step as f32 * 0.05);
assert!(
(1.0 - SPELL_AREA_BREATH_DEPTH - 1e-3..=1.0 + SPELL_AREA_BREATH_DEPTH + 1e-3)
.contains(&v),
"the hold never drifts off its base: {v}"
);
}
// Removal: the area fade, not the kit's — and `reap_complete` is what hands the budget
// slot back rather than waiting out the anchor's own teardown.
let mut ending = area();
ending.age = 8.0;
ending.reaped = SPELL_AREA_FADE * 0.5;
assert!(!ending.reap_complete());
assert!(ending.envelope() < 0.55 && ending.envelope() > 0.45);
ending.reaped = SPELL_AREA_FADE;
assert_eq!(ending.envelope(), 0.0);
assert!(ending.reap_complete());
// A KIT light of the same age is NOT finished at the area fade's length — the two clocks
// are genuinely separate.
let mut kit_ending = SpellLight::new(1.0, 0.0, SpellLightMode::Kit);
kit_ending.reaped = SPELL_REAP_FADE * 0.5;
assert!(!kit_ending.reap_complete());
}
fn test_anims(clips: &[AnimClip]) -> ModelAnimations {
ModelAnimations {
graph: Handle::default(),
@@ -83,9 +83,11 @@ pub(super) use lifecycle::advance_fx_anim;
// switch and the mode each lane spawns it in. Exported at `pub(crate)` because the spawners live
// outside this module: `entities::carried_light` holds the one spawn helper (and the firework
// GameObject's own branch), and `entities.rs` registers the two systems.
// MONKEY (area spell light): plus the persistent ground lane's marker, which `dest_fx` reads for
// the impact dedupe and the budget reads to evict a standing pool last.
pub(crate) use lifecycle::{
advance_spell_lights, budget_spell_lights, spell_lights_enabled, SpellLight, SpellLightMode,
SPELL_BURST_SPAN,
advance_spell_lights, budget_spell_lights, spell_lights_enabled, AreaSpellLight, SpellLight,
SpellLightMode, SPELL_BURST_SPAN,
};
use lifecycle::{decay_span, FxAnimLife, FxDecay};
+124 -13
View File
@@ -26,6 +26,7 @@
//! corruption seen during the first experiment. Only the private-layer proxy casts.
use bevy::asset::RenderAssetUsages;
use bevy::ecs::entity::EntityHashSet;
use bevy::camera::visibility::{NoFrustumCulling, RenderLayers};
use bevy::light::{
CascadeShadowConfigBuilder, DirectionalLight, DirectionalLightShadowMap, NotShadowReceiver,
@@ -45,7 +46,13 @@ use benilla_assets::materials::WowModelMaterial;
use benilla_formats::ModelBlend;
use benilla_world::billboard::BillboardCard;
use benilla_world::interact::PickMesh;
use benilla_world::lighting::{ShadowDistance, ShadowFilterGaussian, WowLighting};
// MONKEY (moon shadows): `sun_shadow_strength` is the rig's half of the hand-over law — WHICH body
// this one directional light is aimed at. Imported rather than mirrored so the aim and the packed
// weight can never disagree about which map the receivers are reading.
use benilla_world::lighting::{
moon_shadow_weight, sun_shadow_strength, MoonShadowStrength, ShadowBody, ShadowHandover,
ShadowDistance, ShadowFilterGaussian, WowLighting,
};
use benilla_world::model_render::{ModelKind, ModelPart, ShadowOccluder};
use benilla_world::rig_palette::{RigPalettes, RigPart, RigSkin};
use benilla_world::view::WorldCamera;
@@ -227,6 +234,13 @@ fn sun_snap_due(held: Option<Vec3>, live: Vec3) -> bool {
/// celestial TO-sun direction, elevation clamped to [`MIN_SHADOW_SUN_ELEVATION`,
/// `MAX_SHADOW_SUN_ELEVATION`]. Azimuth is preserved so shadows sweep with the real sun; only the
/// height is tamed so a set/zenith sun can't invert or degenerate the basis.
///
/// MONKEY (moon shadows): the MOON is aimed through this same function, unchanged. The clamp is
/// exactly what a moonrise needs — the white moon climbs from −10° through 0° to +55°, and an
/// unclamped basis at 1° of elevation stretches the cascade's footprint toward the horizon until
/// every shadow in it is a smear. The moon also has no separate azimuth problem to solve: its
/// bearing is the SAME constant 45° the celestial sun uses (`daynight::moon_direction`), so a moon
/// shadow falls along a midday sun shadow's compass line, only longer.
fn shadow_sun_travel(to_sun: Vec3) -> Vec3 {
let s = to_sun.normalize_or_zero();
if s == Vec3::ZERO {
@@ -259,8 +273,8 @@ struct ShadowCameraLayer {
had_layers: bool,
}
/// The two ordered phases of the shadow feature within `Last`: the rig runs FIRST (spawns/aims the
/// sun, publishes [`ShadowFrame`]), then every lane runs in [`ShadowSet::Lanes`] — reading the frame
/// MONKEY (moon shadows): the rig runs in PostUpdate BEFORE Propagate (spawns/aims the
/// sun, publishes [`ShadowFrame`]), then lanes run in Last in [`ShadowSet::Lanes`] — reading the frame
/// to build its casters and ORing its demand back in for the rig to read next frame.
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub(crate) enum ShadowSet {
@@ -280,6 +294,8 @@ pub(crate) struct ShadowDemand(pub bool);
#[derive(Resource, Default)]
pub(crate) struct ShadowFrame {
pub active: bool,
/// MONKEY (moon shadows): keep cached geometry, but spend no rebuild work on an off night.
pub suspended: bool,
pub light_position: Vec3,
pub sun_direction: Vec3,
/// Reach for short casters (creatures/gameobjects — at most a body tall).
@@ -347,12 +363,15 @@ impl Plugin for ShadowCorePlugin {
.init_resource::<ShadowDemand>()
.init_resource::<ShadowFrame>()
// The rig runs before the lanes every frame; the lanes read the frame it publishes.
.configure_sets(Last, (ShadowSet::Rig, ShadowSet::Lanes).chain())
// MONKEY (moon shadows): after ALL Update work (lighting resolve + strength bridge),
// before this frame's transform propagation and light-buffer packing. Last was too
// late: a time jump had already packed the new body's weight over the old basis.
.configure_sets(PostUpdate, ShadowSet::Rig.before(bevy::transform::TransformSystems::Propagate))
// MONKEY (sun shadow perf): the quality dials land BEFORE the rig each frame, and
// unconditionally — a `shadowMapSize` write must take even while the lanes are off, so
// turning shadows back on doesn't render one frame at the previous size.
.add_systems(
Last,
PostUpdate,
(apply_shadow_quality, manage_rig)
.chain()
.in_set(ShadowSet::Rig),
@@ -433,19 +452,24 @@ fn manage_rig(
state: Res<State<ClientState>>,
video: Res<VideoConfig>,
lighting: Res<WowLighting>,
time: Res<Time>,
mut handover: ResMut<ShadowHandover>,
mut demand: ResMut<ShadowDemand>,
mut frame: ResMut<ShadowFrame>,
mut rig: ResMut<ShadowRigState>,
// MONKEY (distance slider): bridge the app-side `shadowDistance` to benilla-world so
// `global_light` can pack it for the receivers' edge fade (which must fade at THIS distance).
mut shadow_distance_out: ResMut<ShadowDistance>,
// MONKEY (moon shadows): read-only here — the settings registry owns the write. `0` keeps the
// rig aimed at the celestial sun all night, i.e. the pre-feature behaviour exactly.
moon_strength: Res<MoonShadowStrength>,
mut commands: Commands,
mut materials: ResMut<Assets<ShadowCasterMaterial>>,
mut cameras: Query<
(Entity, Option<&mut RenderLayers>, Option<&ShadowCameraLayer>),
With<WorldCamera>,
>,
mut suns: Query<&mut Transform, With<ShadowSun>>,
mut suns: Query<(&mut Transform, &mut DirectionalLight), With<ShadowSun>>,
cameras_for_position: Query<&GlobalTransform, With<WorldCamera>>,
) {
let in_world = *state.get() == ClientState::InWorld;
@@ -459,6 +483,13 @@ fn manage_rig(
// (they run after us, in ShadowSet::Lanes). One-frame lag on rig spawn — imperceptible.
let wanted = demand.0 && in_world;
demand.0 = false;
// MONKEY (moon shadows): publish zero immediately on a body change. Only the actual write
// below acknowledges the basis; fresh spawns may need a deferred-command frame to get here.
let sun_w = sun_shadow_strength(lighting.celestial_dir().y);
handover.request(sun_w,
moon_shadow_weight(lighting.celestial_dir().y, lighting.moon_dir().y),
moon_strength.0, wanted, time.delta_secs());
frame.suspended = sun_w <= 0.0 && moon_strength.0 <= 0.0;
if !wanted {
if let Some(entity) = rig.sun.take() {
@@ -485,6 +516,17 @@ fn manage_rig(
return;
}
// MONKEY (moon shadows): an externally removed light is not an acknowledged live rig.
// Deferred first spawns have no written basis yet, so they are allowed their creation frame.
if rig.sun_written.is_some() && rig.sun.is_some_and(|sun| !suns.contains(sun)) {
rig.sun = None;
rig.sun_written = None;
*handover = ShadowHandover::default();
handover.request(sun_w,
moon_shadow_weight(lighting.celestial_dir().y, lighting.moon_dir().y),
moon_strength.0, wanted, 0.0);
}
if rig.material.is_none() {
rig.material = Some(materials.add(ShadowCasterMaterial {}));
}
@@ -495,7 +537,7 @@ fn manage_rig(
// The light exists to populate a shadow map; WoW's shader lighting stays
// authoritative, so it must not add a second diffuse term.
illuminance: 0.0,
shadows_enabled: true,
shadows_enabled: !frame.suspended,
shadow_depth_bias: 0.02,
shadow_normal_bias: 0.8,
..default()
@@ -558,8 +600,15 @@ fn manage_rig(
.map(GlobalTransform::translation)
.unwrap_or(Vec3::ZERO);
// MONKEY (moving sun): aim at the VISIBLE celestial sun (rises/sets), clamped in elevation.
let sun_direction = shadow_sun_travel(lighting.celestial_dir());
// MONKEY (moon shadows): request/acknowledge rather than assuming the clock is continuous.
// The published weight stays zero across the write and its propagation frame, including a
// noon-to-midnight jump, login and a strength-zero-to-enabled transition at midnight.
let aim_at_moon = handover.wanted == Some(ShadowBody::Moon);
let sun_direction = shadow_sun_travel(if aim_at_moon {
lighting.moon_dir()
} else {
lighting.celestial_dir()
});
// MONKEY (sun shadow perf): `shadowCasterReach` scales BOTH reaches by the same factor, after
// the reach law rather than inside it — the law's terms (resolve range, rebuild margin, the
// sun-elevation extension) each mean something, and a dial that rewrote one of them would
@@ -572,15 +621,21 @@ fn manage_rig(
if let Some(sun) = rig.sun {
// Only the rotation matters; QUANTISED to hold the cascade texel snap (see SUN_SNAP_RADIANS).
if sun_snap_due(rig.sun_written, sun_direction) {
if let Ok(mut current) = suns.get_mut(sun) {
if let Ok((mut current, mut light)) = suns.get_mut(sun) {
if light.shadows_enabled == frame.suspended {
light.shadows_enabled = !frame.suspended;
}
if handover.aimed != handover.wanted || sun_snap_due(rig.sun_written, sun_direction) {
*current = Transform::IDENTITY.looking_to(sun_direction, Vec3::Y);
rig.sun_written = Some(sun_direction);
if let Some(body) = handover.wanted {
handover.aim_written(body);
}
}
}
}
frame.active = true;
frame.active = rig.sun.is_some_and(|sun| suns.contains(sun));
frame.light_position = light_position;
frame.sun_direction = sun_direction;
frame.entity_reach = entity_reach;
@@ -676,6 +731,7 @@ pub(crate) fn empty_cutout_mesh() -> Mesh {
pub(crate) fn collect_entity_geometry(
parts: &Query<
(
Entity,
&PickMesh,
&ModelPart,
Option<&GlobalTransform>,
@@ -694,9 +750,10 @@ pub(crate) fn collect_entity_geometry(
doodad_reach: f32,
positions: &mut Vec<[f32; 3]>,
indices: &mut Vec<u32>,
mut built_parts: Option<&mut EntityHashSet>,
) -> (u32, u32) {
let (mut admitted, mut rejected) = (0u32, 0u32);
for (pick, part, global, rig_part, occluder, _material) in parts.iter() {
for (entity, pick, part, global, rig_part, occluder, _material) in parts.iter() {
if !casts_realtime_shadow(part.kind, part.blend, want_creatures, want_environment) {
continue;
}
@@ -734,7 +791,15 @@ pub(crate) fn collect_entity_geometry(
}
let added = positions.len() as u32 - base;
if added != 0 {
let before = indices.len();
append_triangles(&pick.0.indices, added, base, indices);
// MONKEY (moon shadows): readiness is a successful triangle append, not admission
// or a material handle. Missing palettes / pending mesh data must keep the oval.
if indices.len() > before {
if let Some(built) = built_parts.as_deref_mut() {
built.insert(entity);
}
}
}
}
(admitted, rejected)
@@ -850,6 +915,52 @@ mod tests {
assert_eq!(shadow_sun_travel(Vec3::ZERO), Vec3::NEG_Z);
}
/// MONKEY (moon shadows): the RIG's half of the hand-over — WHICH body the one directional
/// light is aimed at, and that the swap lands where nothing is being cast.
///
/// `manage_rig`'s predicate is `moonShadowStrength > 0 && sun_shadow_strength(sun.y) <= 0`.
/// What has to hold for that to be a swap and not a POP is that on the frame it flips, BOTH
/// weights are zero — the sun's because the predicate says so, the moon's because
/// `moon_shadow_weight` has its own elevation ramp and the moon is still under the horizon
/// there. (The weights' own continuity over the whole game day is
/// `benilla_world`'s `the_two_shadow_weights_never_overlap_and_neither_jumps`; this is the
/// aiming side of the same contract, and the reason the two are not one test is that the aim
/// lives in this crate and the weights in that one.)
///
/// The third assertion is the one a reviewer should look at hardest: a moon at or under the
/// horizon must still produce a DOWNWARD, elevation-clamped basis. Unclamped, a moonrise aim
/// either inverts (a below-horizon body's travel points up, so every shadow falls the wrong
/// way) or stretches the cascade's footprint toward the horizon until the map resolves nothing.
#[test]
fn the_rig_hands_over_to_the_moon_only_where_neither_body_casts() {
use benilla_world::lighting::{moon_shadow_weight, sun_shadow_strength};
// Dusk, the frame the aim flips: the sun has just reached the horizon.
let sun_down = -0.02_f32;
assert_eq!(sun_shadow_strength(sun_down), 0.0, "the sun casts nothing at the horizon");
// …and the moon is still under it (the shipped tables put moonrise ~1h45m later).
let moon_under = -0.17_f32;
assert_eq!(
moon_shadow_weight(sun_down, moon_under),
0.0,
"the moon must not start casting the instant the sun stops — the swap has to happen \
where BOTH weights are zero or the map's contents change under a live weight"
);
// While the sun is still up the moon is refused outright, whatever its elevation.
assert!(sun_shadow_strength(0.5) > 0.0);
assert_eq!(moon_shadow_weight(0.5, 0.9), 0.0, "one body casts at a time");
// A high midnight moon does cast, and at full weight (the strength dial scales it later).
assert_eq!(moon_shadow_weight(sun_down, 0.82), 1.0);
// The aim itself: a below-horizon moon still travels DOWN, pinned at the floor elevation —
// the same treatment `shadow_sun_travel` gives a set sun.
let t = shadow_sun_travel(Vec3::new(0.7, moon_under, 0.7).normalize());
assert!(t.y < 0.0, "a moonrise basis still points down");
assert!(
((-t.y).asin() - MIN_SHADOW_SUN_ELEVATION).abs() < 1e-3,
"a low moon is pinned to the floor elevation, so its shadows cannot smear to the horizon"
);
}
#[test]
fn the_shadow_sun_snaps_on_first_write_holds_under_drift_and_rearms() {
let held = Vec3::new(0.3, -0.8, 0.52).normalize();
@@ -1968,6 +1968,321 @@ fn defaults_resets_the_graphics_page_to_registered_defaults() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
// ── MONKEY (advanced graphics): the Advanced Graphics page ───────────────────────────────────
/// The name every row on the new page is derived from.
const ADVGFX: &str = "BenillaOptionsFrameContainerBodyAdvancedGraphics";
#[test]
fn advanced_graphics_fire_and_spell_sliders_read_and_write_the_full_range() {
let mut s = audio_harness();
s.set_cvar_host("fireLightGain", "3.25");
s.set_cvar_host("spellLightGain", "3.25");
let mut s = harness_on(s);
s.run("ShowUIPanel(BenillaOptionsFrame) \
BenillaOptionsFrameCategoryListRowAdvancedGraphics:Click()")
.unwrap();
let _ = s.take_cvar_changes();
for (row, cvar) in [("RowFireLight", "fireLightGain"), ("RowSpellLights", "spellLightGain")] {
assert_eq!(s.eval::<f32>(&format!(
"return {ADVGFX}{row}ControlSlider:GetValue()"
)).unwrap(), 3.25);
assert_eq!(s.eval::<String>(&format!(
"return {ADVGFX}{row}ControlValue:GetText()"
)).unwrap(), "325%");
for value in ["4", "2.75"] {
s.run(&format!("{ADVGFX}{row}ControlSlider:SetValue({value})")).unwrap();
assert!(s.take_cvar_changes().contains(&(cvar.to_string(), value.to_string())));
assert_eq!(s.cvar(cvar).as_deref(), Some(value));
}
}
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
#[test]
fn advanced_graphics_selects_its_locale_at_load_including_yards() {
for (locale, title, distance) in [
("enUS", "Advanced Graphics", "80 yd"),
("ruRU", "Расширенная графика", "80 ярд."),
("deDE", "Advanced Graphics", "80 yd"),
] {
let s = audio_harness();
s.run(&format!("function GetLocale() return '{locale}' end")).unwrap();
let s = harness_on(s);
s.run("ShowUIPanel(BenillaOptionsFrame) \
BenillaOptionsFrameCategoryListRowAdvancedGraphics:Click()")
.unwrap();
assert_eq!(s.eval::<String>(
"return BenillaOptionsFrameContainerTitle:GetText()"
).unwrap(), title);
assert_eq!(s.eval::<String>(&format!(
"return {ADVGFX}RowShadowDistanceControlValue:GetText()"
)).unwrap(), distance);
assert!(s.eval::<bool>(
"return BENILLA_TOOLTIP_NIGHT_DARKNESS == BENILLA_ADVGFX.tips.NIGHT_DARKNESS"
).unwrap());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
}
/// **The page reads the lighting CVars on select** — every control flavour on it at once, which
/// is the point of checking one page rather than one row: this is the first page in the window
/// carrying a dropdown, a checkbox and four sliders whose store is a benilla CVar with no
/// reference counterpart, and the first whose labels come out of a table rather than a literal.
///
/// The three rows that moved here off Graphics are checked BOTH ways: present here, and gone from
/// there. A row that was copied rather than moved would show up as two live controls over one
/// CVar, which nothing else in this file would catch.
#[test]
fn the_advanced_graphics_page_reads_the_lighting_cvars_on_select() {
let mut s = audio_harness();
s.set_cvar_host("shadowMapSize", "4096");
s.set_cvar_host("interiorShadows", "0");
let s = harness_on(s);
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowAdvancedGraphics:Click()")
.unwrap();
assert!(s
.eval::<bool>(&format!("return {ADVGFX}:IsVisible()"))
.unwrap());
assert_eq!(
s.eval::<String>("return BenillaOptionsFrameContainerTitle:GetText()")
.unwrap(),
"Advanced Graphics",
"the page title is the category row's label"
);
assert!(
s.eval::<bool>("return BenillaOptionsFrameContainerDefaults:IsEnabled() ~= 0")
.unwrap(),
"Defaults is live on a page with rows"
);
// The dropdowns: the ladder reads the registered default, the resolution reads the seed.
assert_eq!(
s.eval::<String>(&format!("return {ADVGFX}RowLightingQualityDropdownText:GetText()"))
.unwrap(),
"High",
"a fresh registry is the High preset, member for member (cvars::tests)"
);
assert_eq!(
s.eval::<String>(&format!("return {ADVGFX}RowShadowResolutionDropdownText:GetText()"))
.unwrap(),
"4096"
);
// The checkboxes read the table, not a restated default.
assert!(s
.eval::<bool>(&format!("return {ADVGFX}RowCharacterShadowsCheck:GetChecked()"))
.unwrap());
assert!(!s
.eval::<bool>(&format!("return {ADVGFX}RowInteriorShadowsCheck:GetChecked()"))
.unwrap());
// The sliders, each on its own registry clamp with its own readout grammar.
for (row, text) in [
("RowShadowDistance", "80 yd"),
("RowMoonShadows", "35%"),
("RowFireFlicker", "100%"),
("RowSpellLights", "100%"),
] {
assert_eq!(
s.eval::<String>(&format!("return {ADVGFX}{row}ControlValue:GetText()"))
.unwrap(),
text,
"{row}"
);
}
// The labels come out of the bilingual table (enUS here — benilla's `GetLocale` answers
// nothing else yet).
assert_eq!(
s.eval::<String>(&format!("return {ADVGFX}RowInteriorLightLabel:GetText()"))
.unwrap(),
"Dynamic Interior Lighting"
);
// MOVED, not copied: the Graphics page has no shadow rows left.
for row in ["RowCharacterShadows", "RowWorldShadows", "RowShadowDistance"] {
assert!(
!s.eval::<bool>(&format!(
"return BenillaOptionsFrameContainerBodyGraphics{row} ~= nil"
))
.unwrap(),
"{row} is still seated on the Graphics page"
);
}
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **The Lighting Quality row writes ONE CVar** — and that is the whole of its contract with this
/// window. The preset's members are the host's to write (`cvars::LIGHTING_PRESETS`), so a page
/// that wrote them here would be a second copy of that table, silently drifting from the one the
/// console and the native panel use. What this pins is the boundary: one write, the engine's own
/// spelling, and nothing else queued.
#[test]
fn the_lighting_quality_row_writes_one_cvar_and_leaves_the_members_to_the_host() {
let mut s = harness_on(audio_harness());
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowAdvancedGraphics:Click()")
.unwrap();
let _ = s.take_cvar_changes();
// The entry click's own body (`OptionsDropdownEntry_OnClick`), which is the row's Set
// followed by the capsule's redisplay — driven directly so the test does not depend on the
// shared dropdown kit's list geometry.
s.run(&format!(
"OptionsRow_Set({ADVGFX}RowLightingQuality, \"Low\") \
OptionsDropdown_ShowValue({ADVGFX}RowLightingQuality)"
))
.unwrap();
assert_eq!(
s.take_cvar_changes(),
vec![("lightingQuality".to_string(), "Low".to_string())],
"one write, and the value is the engine's own string — never the localised label"
);
assert_eq!(
s.eval::<String>(&format!("return {ADVGFX}RowLightingQualityDropdownText:GetText()"))
.unwrap(),
"Low"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **The two darkness dials read backwards, and that is the bug this row shape exists to avoid.**
/// Their store is a GAIN — `nightGain` 0.2..1.5, where 0.2 is the darkest — while the rows are
/// named for the darkness. Left is darker either way; without the `darkness` readout the NUMBER
/// beside the label would climb as the world got lighter, under a label that says "Darkness".
#[test]
fn the_darkness_sliders_read_out_so_that_left_is_darker() {
let mut s = harness_on(audio_harness());
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowAdvancedGraphics:Click()")
.unwrap();
// The registered default, 0.45 of 0.2..1.5, is (1.5 − 0.45) / 1.3 = 81 % of the way dark.
assert_eq!(
s.eval::<String>(&format!("return {ADVGFX}RowNightDarknessControlValue:GetText()"))
.unwrap(),
"81%"
);
// The darkest gain reads 100 %, the brightest 0 %; the reference night is gain 1.0.
for (value, text) in [("0.2", "100%"), ("1.5", "0%"), ("1.0", "38%"), ("0.85", "50%")] {
s.run(&format!(
"{ADVGFX}RowNightDarknessControlSlider:SetValue({value})"
))
.unwrap();
assert_eq!(
s.eval::<String>(&format!("return {ADVGFX}RowNightDarknessControlValue:GetText()"))
.unwrap(),
text,
"nightGain {value}"
);
}
// …and the STORE keeps the gain, unchanged — the readout is a readout.
assert!(
s.take_cvar_changes()
.contains(&("nightGain".to_string(), "0.85".to_string())),
"the slider writes the gain the engine reads, not the percentage it shows"
);
assert_eq!(
s.eval::<String>(&format!("return {ADVGFX}RowInteriorDarknessControlValue:GetText()"))
.unwrap(),
"77%",
"interiorGain 0.5 of 0.2..1.5"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **The page's dependencies**, in the reference's own shape: a master's switch greys what it
/// gates, and the CVars keep their values throughout so turning the master back on finds its
/// children where they were left.
#[test]
fn the_advanced_graphics_masters_grey_what_they_gate() {
let s = harness_on(audio_harness());
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowAdvancedGraphics:Click()")
.unwrap();
// A greyed row wears the DISABLE font — 1.12's own signal, and the one `OptionsRow_SetEnabled`
// sets on every flavour of row whatever else it does to the control. Compared as an OBJECT,
// not a name: `GetFontObject` answers the object (`script::font`).
let live = |s: &UiScript, row: &str| -> bool {
s.eval::<bool>(&format!(
"return {ADVGFX}{row}Label:GetFontObject() == GameFontNormal"
))
.unwrap()
};
assert!(live(&s, "RowInteriorShadows"));
assert!(live(&s, "RowShadowResolution"));
// Dynamic Interior Lighting off greys the indoor torch row — the CVar's own documented
// dependency ("needs interiorLight").
s.run(&format!("{ADVGFX}RowInteriorLightCheck:Click()"))
.unwrap();
assert!(!live(&s, "RowInteriorShadows"));
// Both sun lanes off leave nothing for the cascade rows to size.
s.run(&format!("{ADVGFX}RowCharacterShadowsCheck:Click()"))
.unwrap();
s.run(&format!("{ADVGFX}RowWorldShadowsCheck:Click()"))
.unwrap();
assert!(!live(&s, "RowShadowResolution"));
assert!(!live(&s, "RowShadowDistance"));
// …and one of them back on wakes them, with their values untouched.
s.run(&format!("{ADVGFX}RowWorldShadowsCheck:Click()"))
.unwrap();
assert!(live(&s, "RowShadowResolution"));
assert_eq!(
s.eval::<String>(&format!("return {ADVGFX}RowShadowDistanceControlValue:GetText()"))
.unwrap(),
"80 yd"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **The page follows a write it did not make.** This is the one page in the window whose CVars
/// move from outside it — the Lighting Quality row's members are written by the host a frame or
/// two after the click, and the console and the dev lighting panel reach the same rows while the
/// page is open. Without the poll the player would pick a preset and watch nothing below it move.
#[test]
fn the_page_repaints_when_a_host_write_moves_a_row_under_it() {
let mut s = harness_on(audio_harness());
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowAdvancedGraphics:Click()")
.unwrap();
// The page has to be effectively visible before its OnUpdate is in the sweep at all.
s.resolve();
assert!(s
.eval::<bool>(&format!("return {ADVGFX}RowExteriorShadowsCheck:GetChecked()"))
.unwrap());
// What the host does when a preset's members land: a mirror write, with no CVAR_UPDATE and no
// Lua write behind it.
s.set_cvar_host("exteriorShadows", "0");
s.set_cvar_host("moonShadowStrength", "0");
// Under the poll interval: nothing has happened yet.
s.tick(0.1);
assert!(s
.eval::<bool>(&format!("return {ADVGFX}RowExteriorShadowsCheck:GetChecked()"))
.unwrap());
// Past it: one refresh, and the page tells the truth again.
s.tick(0.2);
assert!(!s
.eval::<bool>(&format!("return {ADVGFX}RowExteriorShadowsCheck:GetChecked()"))
.unwrap());
assert_eq!(
s.eval::<String>(&format!("return {ADVGFX}RowMoonShadowsControlValue:GetText()"))
.unwrap(),
"0%"
);
// The refresh writes NOTHING back — it is a read of the table, not a round trip.
assert!(
s.take_cvar_changes().is_empty(),
"the poll must never write a CVar"
);
// …and a settled page costs nothing: the signature has not moved, so no further refresh runs.
s.tick(0.3);
assert!(s.take_cvar_changes().is_empty());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Controls is the DEFAULT page and has rows since 0961: opening the window lands on it with
/// Defaults armed, and the rows read the table — Sticky Targeting INVERTED (checked when
/// `deselectOnClick` is "0", the 1.12 interface panel's own arm), the plain flags direct.
@@ -2560,9 +2875,66 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() {
"AudioRowBackgroundSound",
),
("BENILLA_TOOLTIP_BRIGHTNESS", "GraphicsRowBrightness"),
("BENILLA_TOOLTIP_CHARACTER_SHADOWS", "GraphicsRowCharacterShadows"),
("BENILLA_TOOLTIP_WORLD_SHADOWS", "GraphicsRowWorldShadows"),
("BENILLA_TOOLTIP_SHADOW_DISTANCE", "GraphicsRowShadowDistance"),
// MONKEY (advanced graphics): the whole Advanced Graphics page. Every row on it is
// benilla's own mechanism — 1.12 has no realtime shadow, no fixture-lit interior and
// no invented light — so there is no `OPTION_TOOLTIP_*` any of them could resolve,
// and each carries a `BENILLA_` key for the same reason the four above it do. The
// first three are the rows that MOVED here off the Graphics page: same keys, same
// texts, new seat, which is why they read `AdvancedGraphicsRow…` now.
(
"BENILLA_TOOLTIP_CHARACTER_SHADOWS",
"AdvancedGraphicsRowCharacterShadows",
),
(
"BENILLA_TOOLTIP_WORLD_SHADOWS",
"AdvancedGraphicsRowWorldShadows",
),
(
"BENILLA_TOOLTIP_SHADOW_DISTANCE",
"AdvancedGraphicsRowShadowDistance",
),
(
"BENILLA_TOOLTIP_LIGHTING_QUALITY",
"AdvancedGraphicsRowLightingQuality",
),
(
"BENILLA_TOOLTIP_SHADOW_RESOLUTION",
"AdvancedGraphicsRowShadowResolution",
),
(
"BENILLA_TOOLTIP_MOON_SHADOWS",
"AdvancedGraphicsRowMoonShadows",
),
(
"BENILLA_TOOLTIP_INTERIOR_LIGHT",
"AdvancedGraphicsRowInteriorLight",
),
(
"BENILLA_TOOLTIP_INTERIOR_SHADOWS",
"AdvancedGraphicsRowInteriorShadows",
),
(
"BENILLA_TOOLTIP_EXTERIOR_SHADOWS",
"AdvancedGraphicsRowExteriorShadows",
),
("BENILLA_TOOLTIP_TORCH_SOFT", "AdvancedGraphicsRowTorchSoft"),
("BENILLA_TOOLTIP_FIRE_LIGHT", "AdvancedGraphicsRowFireLight"),
(
"BENILLA_TOOLTIP_FIRE_FLICKER",
"AdvancedGraphicsRowFireFlicker",
),
(
"BENILLA_TOOLTIP_SPELL_LIGHTS",
"AdvancedGraphicsRowSpellLights",
),
(
"BENILLA_TOOLTIP_NIGHT_DARKNESS",
"AdvancedGraphicsRowNightDarkness",
),
(
"BENILLA_TOOLTIP_INTERIOR_DARKNESS",
"AdvancedGraphicsRowInteriorDarkness",
),
];
if let Some((_, want_row)) = BENILLA_OWNED.iter().find(|(k, _)| *k == key) {
assert_eq!(row, *want_row, "{row}: not this row's string");
@@ -2623,7 +2995,12 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() {
// 21-step grey ramp, which this page does not have (see the guard above). 77 -> 78.
// MONKEY (world shadows): +3 for Real Character Shadows, World Shadows and the Shadow
// Distance slider (all benilla-owned keys, carved out in BENILLA_OWNED above). 78 -> 81.
assert_eq!(checked, 81, "every tipped row carries a live key");
// MONKEY (advanced graphics): +12, the rows the new page ADDS — Lighting Quality, Shadow
// Resolution, Moon Shadows, Dynamic Interior Lighting, Indoor and Outdoor Torch Shadows,
// Torch Shadow Softness, Fire Light Brightness, Fire Flicker, Spell Lights, Night Darkness
// and Interior Darkness. The three rows that MOVED onto it are already in the 81: a row
// changing pages does not change this count, only its entry in BENILLA_OWNED. 81 -> 93.
assert_eq!(checked, 93, "every tipped row carries a live key");
assert_eq!(
untipped,
vec![
@@ -2674,6 +3051,9 @@ fn every_flavor_of_row_raises_its_plate_from_the_page_it_lives_on() {
"Controls",
"Audio",
"Graphics",
// MONKEY (advanced graphics): the tenth category, and the one with the most
// benilla-owned descriptions on it — every row here raises a `BENILLA_TOOLTIP_*`.
"AdvancedGraphics",
"Nameplates",
"Combat",
"Interface",
@@ -2745,7 +3125,11 @@ fn every_flavor_of_row_raises_its_plate_from_the_page_it_lives_on() {
// sliders.
// MONKEY (world shadows): +3 for Real Character Shadows, World Shadows and the Shadow
// Distance slider (all benilla-owned keys, carved out in BENILLA_OWNED above). 78 -> 81.
assert_eq!(raised, 81, "every row but Auto Loot raises a description");
// MONKEY (advanced graphics): +12 for the rows the new page ADDS (the three it inherits were
// already counted, on their old page). 81 -> 93 — and the page is where this test's own
// teeth bite hardest, since it is the first one to seat a dropdown, a checkbox and a slider
// whose descriptions are all benilla's.
assert_eq!(raised, 93, "every row but Auto Loot raises a description");
}
/// The **Combat page** (decision 1134) — the first rows in this window whose store is a
@@ -3705,7 +4089,11 @@ fn the_defaults_button_is_armed_by_rows_not_by_a_category() {
.split(',')
.map(str::to_string)
.collect();
assert_eq!(keys.len(), 9, "the nine 1.15.9 categories: {keys:?}");
// MONKEY (advanced graphics): ten, not nine — the era's own tree has no Advanced Graphics
// page, because the era client has no dynamic light and shadow system to put on one. It is
// benilla's own category, seated under Graphics in the System group, and it is held to the
// same bar as the era's nine: it opens onto rows, and Defaults is live on it.
assert_eq!(keys.len(), 10, "the nine 1.15.9 categories + ours: {keys:?}");
for key in &keys {
let has_rows = s
.eval::<bool>(&format!(
+17 -4
View File
@@ -267,6 +267,13 @@ pub(crate) struct VideoConfig {
/// proxies. Trimming it is the direct dial on caster POPULATION — the input to both lanes'
/// per-rebuild cost — at the risk of a tall caster's shadow popping in as you approach.
pub(crate) shadow_caster_reach: f32,
/// MONKEY (moon shadows): how dark a MOON-shadowed fragment is allowed to get at night
/// (`moonShadowStrength`, 0..1, default 0.35 = the fragment keeps 65 % of the night
/// directional term). Bridged to [`benilla_world::lighting::MoonShadowStrength`] by
/// `dynamic_interior`, beside the spell gain and for the same reasons — one live `f32`, one
/// resource, one guard. `0` is the faithful null: every consumer of the resource guards on it,
/// so a night at 0 renders as the build before the moon lane existed.
pub(crate) moon_shadow_strength: f32,
/// MONKEY (dynamic interiors): WMO interiors + their props light from the room's live fixtures
/// (`interiorLight`) instead of the baked path. The three knobs are `interiorAmbient` (base
/// ambient, 0..1), `interiorFill` (per-fixture bounce gain, 0..2) and `interiorExposure`
@@ -349,8 +356,8 @@ pub(crate) struct VideoConfig {
/// MONKEY (interior debug): the interior-lane diagnostic overlay (`interiorDebug`, 0..4). See
/// [`benilla_world::lighting::DynamicInteriors::debug`].
pub(crate) interior_debug: u32,
/// MONKEY (darkness gains): the exterior night dim (`nightGain`, 0.2..1.5, default **0.8** =
/// nights 20 % darker). Bridged to `DynamicInteriors::night_gain`, which the light packer folds
/// MONKEY (darkness gains): the exterior night dim (`nightGain`, 0.2..1.5, default **0.45** =
/// nights 55 % darker). Bridged to `DynamicInteriors::night_gain`, which the light packer folds
/// into the packed ambient/diffuse/specular rows on a `mix(1, gain, night_w)` ramp — so it is
/// exactly inert while the sun is up and live the frame it changes after dark.
pub(crate) night_gain: f32,
@@ -411,6 +418,8 @@ impl Default for VideoConfig {
character_shadow_rate: crate::shadow_core::DEFAULT_SHADOW_RATE,
world_shadow_rate: crate::shadow_core::DEFAULT_SHADOW_RATE,
shadow_caster_reach: 1.0,
// MONKEY (moon shadows): 0.35 — a hint of a silhouette, not a daylight-hard shadow.
moon_shadow_strength: 0.35,
// The cvar defaults are the source of truth at load; these only stand in until then.
interior_light: true,
interior_ambient: 0.015,
@@ -507,7 +516,7 @@ pub(crate) fn on_cvar(
// Display mode (1627) — the reference's own polarity: `1` is WINDOWED (the row is
// "Windowed Mode"). `apply_window_mode` pushes it to the window when this moves.
"gxwindow" => cfg.display = display_from_flag(v),
// ── MONKEY (lighting): the dynamic light + shadow system's 29 rows ────────────────────
// ── MONKEY (lighting): the dynamic light + shadow system's 30 rows ────────────────────
// They live in THIS observer, and not in one of their own beside `shadow_core` /
// `dynamic_interior`, because of the law the arm above states: *each arm writes only its
// own resource*. Every one of these knobs IS a field of [`VideoConfig`] — the lanes read
@@ -523,7 +532,7 @@ pub(crate) fn on_cvar(
//
// Clamps are each row's own, stated beside it, exactly as for the reference rows above;
// the `ours(...)` entries in `cvars::REGISTERED` carry the matching defaults, and
// `cvars::tests::registered_defaults_mirror_the_code_truths` welds all 29 pairs.
// `cvars::tests::registered_defaults_mirror_the_code_truths` welds all 30 pairs.
"worldshadows" => cfg.world_shadows = ev.flag(),
"charactershadows" => cfg.character_shadows = ev.flag(),
"shadowdistance" => {
@@ -557,6 +566,10 @@ pub(crate) fn on_cvar(
*crate::shadow_core::CASTER_REACH_RANGE.end(),
)
}
// MONKEY (moon shadows): the night lane's shadow darkness. 0 IS meaningful (the lane off,
// bit-identical to the pre-feature night), so it floors at 0 rather than at a
// minimum-useful value; 1 is a daylight-hard silhouette by moonlight.
"moonshadowstrength" => cfg.moon_shadow_strength = v.clamp(0.0, 1.0),
// MONKEY (dynamic interiors): the interior lane's on/off + knobs, clamped at the edge like
// every other numeric row. `dynamic_interior::bridge` publishes them to benilla-world.
"interiorlight" => cfg.interior_light = ev.flag(),
+8
View File
@@ -138,6 +138,7 @@ fn update_world_shadows(
mut world_active: ResMut<WorldShadowActive>,
parts: Query<
(
Entity,
&PickMesh,
&ModelPart,
Option<&GlobalTransform>,
@@ -163,6 +164,12 @@ fn update_world_shadows(
teardown(&mut lane, &mut commands, &mut meshes, &mut cutout_materials);
return;
}
// MONKEY (moon shadows): no receiver can use the off-night map. Preserve cached geometry,
// but suspend BOTH entity and retained-world rebuilds until a body can cast again.
if frame.suspended {
lane.env_rate.reset();
return;
}
let Some(material) = frame.material.clone() else {
return;
};
@@ -200,6 +207,7 @@ fn update_world_shadows(
frame.tall_reach,
&mut positions,
&mut indices,
None,
);
let tris = (indices.len() / 3) as u32;
restore_mesh_buffers(mesh, positions, indices);
@@ -22,6 +22,40 @@
// it fades in rather than popping at the resolve boundary.
const SHADOW_EDGE_BAND: f32 = 14.0;
// MONKEY (moon shadows): the `fog_params.z` lane decodes. ONE SIGNED float carries BOTH directional
// weights — `+w` the sun's, `-w` the moon's — because `global_light::moon_shadow_weight` guarantees
// they are never both non-zero (the rig holds ONE body's depth map at a time; the receiver loop
// below ASSIGNS, last light wins). The CPU half is `global_light::pack_shadow_lane`; KEEP THE TWO
// ENDS IN SYNC.
//
// Every OTHER reader of this lane was already sign-safe, which is what made the sign free: the
// three `ext_night_w` decodes are `clamp(1 - z, 0, 1)`, and a negative `z` gives them the same 1.0
// the 0.0 they used to read gave them, so the exterior torch lane is bit-identical after dark.
fn sun_shadow_w(lane: f32) -> f32 {
return max(lane, 0.0);
}
fn moon_shadow_w(lane: f32) -> f32 {
return max(-lane, 0.0);
}
// MONKEY (moon shadows): ONE map fetch, the piece both weights share. Split out of
// `realtime_shadow` so `realtime_shadow_terms` can weight the SAME sample twice instead of
// sampling twice — the fetch is a 9-tap Gaussian PCF and the whole feature's cost budget is "no new
// passes, no new textures, and nothing a second time".
fn shadow_fetch(sample_pos: vec4<f32>, normal: vec3<f32>, view_z: f32) -> f32 {
var shadow = 1.0;
if (lights.n_directional_lights > 0u) {
for (var light_id = 0u; light_id < lights.n_directional_lights; light_id = light_id + 1u) {
if ((lights.directional_lights[light_id].flags & 1u) != 0u) {
shadow = shadows::fetch_directional_shadow(light_id, sample_pos, normal, view_z);
break;
}
}
}
return shadow;
}
// The realtime directional-shadow factor at `sample_pos`: 1.0 = lit, 0.0 = fully shadowed.
//
// - `sample_pos` world-space point to sample the shadow map at — a per-fragment position, or a
@@ -43,19 +77,49 @@ fn realtime_shadow(
shadow_range: f32,
night: f32,
) -> f32 {
var shadow = 1.0;
if (lights.n_directional_lights > 0u) {
for (var light_id = 0u; light_id < lights.n_directional_lights; light_id = light_id + 1u) {
if ((lights.directional_lights[light_id].flags & 1u) != 0u) {
shadow = shadows::fetch_directional_shadow(light_id, sample_pos, normal, view_z);
break;
}
}
}
let shadow = shadow_fetch(sample_pos, normal, view_z);
let edge_fade = smoothstep(shadow_range - SHADOW_EDGE_BAND, shadow_range, cam_dist);
return 1.0 - (1.0 - shadow) * night * (1.0 - edge_fade);
}
// MONKEY (moon shadows): BOTH directional weights over ONE fetch — `.x` the SUN arm (exactly what
// `realtime_shadow` above returns, character for character), `.y` the MOON arm.
//
// The three receivers call this instead of `realtime_shadow` because they apply the two arms to
// DIFFERENT terms, and must do so from the same sample:
// · the SUN arm keeps its existing home in each receiver (terrain's `character_shadow_term`, the
// model's and static_gx's `ambient + (lit − ambient) × shadow_term`), untouched;
// · the MOON arm scales the exterior NIGHT law — see each receiver's own note for which term and
// why point lights, torches and spell lights are outside it.
//
// Both arms are written as the ONE original expression rather than factored through a shared
// `occ = (1 − shadow)·(1 − edge_fade)`: float multiply is not associative, and `.x` must be the
// same BITS as before this function existed. At `moon == 0.0` — every daylight frame, every frame
// with `moonShadowStrength 0`, and the ≈20:30-22:17 window with the sun down and the moon not yet
// up — `.y` is exactly `1.0` whatever the map holds, and every receiver's moon branch is guarded on
// exactly that, so it is not entered and the render is the pre-feature one.
fn realtime_shadow_terms(
sample_pos: vec4<f32>,
normal: vec3<f32>,
view_z: f32,
cam_dist: f32,
shadow_range: f32,
night: f32,
moon: f32,
) -> vec2<f32> {
// MONKEY (moon shadows): neither body casts during hand-over or feature-off night. Skip
// the map entirely, including PCF; the old night cost must not grow with an inert moon.
if (night <= 0.0 && moon <= 0.0) {
return vec2<f32>(1.0);
}
let shadow = shadow_fetch(sample_pos, normal, view_z);
let edge_fade = smoothstep(shadow_range - SHADOW_EDGE_BAND, shadow_range, cam_dist);
return vec2<f32>(
1.0 - (1.0 - shadow) * night * (1.0 - edge_fade),
1.0 - (1.0 - shadow) * moon * (1.0 - edge_fade),
);
}
// MONKEY (torch shadows Phase 1): the point/cluster-based torch shadow is DEAD. benilla's world
// camera sets `ClusterConfig::None`, which starves `clusterable_objects` AND the point-shadow prep,
// so `fetch_point_shadow`/the clusterable scan can never fire. The replacement is a from-scratch
+33 -3
View File
@@ -63,7 +63,7 @@ struct WowLight {
light_sun: vec4<f32>, // xyz = world-space sun TRAVEL direction (to-light = −xyz); w unused.
light_spec: vec4<f32>, // rgb = row 9 specular color; w = shininess (20). rgb == 0 disables.
fog_color: vec4<f32>, // rgb = row 7 fog (raw, gamma 0..1); w = enable (>0.5 ⇒ blend).
fog_params: vec4<f32>, // x = fog_start yd; y = fog_end yd; z unused; w = farclip wall.
fog_params: vec4<f32>, // x = fog_start yd; y = fog_end yd; z = signed +sun / -moon shadow weight; w = farclip wall.
_sh: array<vec4<f32>, 6>, // rows 6-11: the model SH coeffs (live in wow_model.wgsl, 0354) — unread by terrain.
sh_c16: vec4<f32>, // row 12: xyz the models' c16 quad band; .w a FREE lane (the 0273 point gain is retired).
_water: array<vec4<f32>, 4>, // rows 13-16: the liquid swatches — unread by terrain.
@@ -281,6 +281,8 @@ struct TerrainVsOut {
// reads `primary` and is therefore bit-identical. Unclamped is safe to interpolate: it is
// linear in `N.L` and the clamp that used to bound it is re-applied per fragment.
@location(9) base_lit: vec3<f32>,
// MONKEY (moon shadows): preserve the point sum BEFORE saturation; primary cannot recover it.
@location(10) point_lit: vec3<f32>,
}
// Terrain's point-light **candidacy half-width** (yd): the reference's guaranteed covered box is
@@ -611,6 +613,7 @@ fn vertex(in: Vertex) -> TerrainVsOut {
let sel = point_light_pick(mcnk_cell_anchor(out.world_position.xyz), MCNK_CELL_HALF);
out.ext_sel = sel;
let points = point_light_eval(sel, out.world_position.xyz, n);
out.point_lit = points;
let sun_lighting = wow_light.light_diffuse.rgb * ndotl;
// The sun/ambient half on its own slot, UNCLAMPED (see `base_lit`) - the night lane's only way
// back to a `primary` that does not already have the unshadowed point term baked into it.
@@ -693,18 +696,24 @@ fn fragment(in: TerrainVsOut) -> @location(0) vec4<f32> {
// MCSH fade-back.
let view_z = (view.view_from_world * in.world_position).z;
let cam_dist = distance(in.world_position.xyz, view.world_position.xyz);
let sun_shadow_strength = wow_light.fog_params.z;
// MONKEY (moon shadows): the lane is SIGNED now — `+` the sun's weight, `-` the moon's (see
// `shadow_hook::sun_shadow_w`). `max(z, 0)` is the identity on every value this lane ever held
// before the feature, so the name, the MCSH gate and the torch-lane decode below are unchanged.
let sun_shadow_strength = shadow_hook::sun_shadow_w(wow_light.fog_params.z);
let moon_shadow_strength = shadow_hook::moon_shadow_w(wow_light.fog_params.z);
// Hoisted out of the `realtime_shadow` call (same expression, same bits) because the torch lane
// below needs the same normal for its normal-offset sample.
let n_lit = normalize(in.world_normal);
let world_shadow = shadow_hook::realtime_shadow(
let shadow_terms = shadow_hook::realtime_shadow_terms(
in.world_position,
n_lit,
view_z,
cam_dist,
wow_light._wmo_fog[1].z,
sun_shadow_strength,
moon_shadow_strength,
);
let world_shadow = shadow_terms.x;
// MONKEY (outdoor torch shadows: terrain): the exterior point term, CAST-SHADOWED at night.
//
// `in.primary` is the Gouraud (per-vertex, per-vertex-CLAMPED) diffuse, and it stays the only
@@ -734,6 +743,19 @@ fn fragment(in: TerrainVsOut) -> @location(0) vec4<f32> {
primary = mix(in.primary, primary_night, ext_night_w);
}
// MONKEY (moon shadows): attenuate the SKY (ambient AND directional) BEFORE adding points
// and saturating. Subtracting from primary loses over-gamut torch energy: sky .2 + point 1
// must remain 1 under a .35 moon shadow, not .93. Keep the entire off arm above untouched;
// neither a zero weight nor a fully lit fragment earns a different Gouraud/clamp path.
if (shadow_terms.y < 1.0) {
var points = in.point_lit;
if (ext_night_w > 0.0) {
points = mix(points,
point_light_eval_shadowed(in.ext_sel, in.world_position.xyz, n_lit), ext_night_w);
}
primary = clamp(in.base_lit * shadow_terms.y + points, vec3<f32>(0.0), vec3<f32>(1.0));
}
// STEP 4: MCSH baked shadow. On the reference path (pixelShaders+specular) terrain is ONE pass
// through `terrainp_s.bls`, which carries the MCSH bit in the blend texture's alpha (1.0 lit / 0.0
// shadowed) and uses it TWO ways (Q15) — NOT the Q11 separate ambient-tint overlay (that's the
@@ -756,6 +778,14 @@ fn fragment(in: TerrainVsOut) -> @location(0) vec4<f32> {
// world shadow fades at night the baked MCSH fades back IN. By day (strength 1) MCSH is fully
// replaced; at night (strength 0) the authored bake returns; dusk crossfades. Character-only
// (lane flag off) never suppresses MCSH.
// MONKEY (moon shadows): this stays on the SUN weight alone — deliberately. The symmetric
// reading ("a moon shadow replaces 35 % of the bake too") lightens MCSH everywhere, including
// the ground BEYOND the cascade where the realtime lane resolves nothing and there would be no
// replacement for what was removed. The two also compose rather than double-count: the moon
// arm scales the light SUM (`primary`, above) and MCSH multiplies the result, and the moon arm
// is ≤ 0.35 of a term the bake is already a flat −30 % of. The visible consequence is that a
// tree at night wears its baked noon-sun shadow AND a faint moon-direction one — which is worth
// eyes on a capture before it is called wrong.
let world_shadow_lane = wow_light.sh_c16.w > 0.5;
let mcsh_suppress = select(0.0, sun_shadow_strength, world_shadow_lane);
let shadow_lit_eff = mix(shadow_lit, 1.0, mcsh_suppress);
+1 -1
View File
@@ -80,7 +80,7 @@ struct WowLight {
_light_sun: vec4<f32>, // 2
_light_spec: vec4<f32>, // 3
fog_color: vec4<f32>, // 4 rgb = Light.dbc row 7 (gamma 0..1); w = enable (>0.5 ⇒ blend)
fog_params: vec4<f32>, // 5 x/y = the SCENE fog start/end yd (unread here — the hull has its own pair); z unused; w = farclip wall (0 ⇒ off)
fog_params: vec4<f32>, // 5 x/y = the SCENE fog start/end yd (unread here — the hull has its own pair); z = the signed directional-shadow weight (MONKEY moon shadows: +sun / -moon; unread here); w = farclip wall (0 ⇒ off)
};
@group(#{MATERIAL_BIND_GROUP}) @binding(90) var<storage, read> w: WowLight;
@@ -123,7 +123,7 @@ struct WowLight {
light_sun: vec4<f32>, // xyz sun TRAVEL dir (to-light = −xyz); w = directional-light enable (>0.5)
light_spec: vec4<f32>, // rgb spec color; w = shininess (terrain's — unused by the matte model path)
fog_color: vec4<f32>, // rgb row-7 fog (gamma); w = enable (>0.5)
fog_params: vec4<f32>, // x=start y=end z=linear-lighting A/B flag w=farclip wall
fog_params: vec4<f32>, // x=start y=end z=signed +sun / -moon shadow weight w=farclip wall
// The global Model2.bls SH rows (6-12): the scene day/night light as an order-2 probe at
// intensity 1 — DC (ambient, `.w` of c10) + the sun's linear/quad bands in c10.xyz / c13 /
// c16.xyz, the disassembled closed form (wow-re model2-bls-vertex-sh.md). Every sun band is
@@ -1800,9 +1800,14 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> WowFragOut {
// MONKEY (shadow hook): the realtime shadow (fetch + edge/night fade) is computed by
// `benilla::shadow_hook`. This lane keeps only the rig-skin SAMPLE-POINT choice + the
// interior/rig exclusion + ambient-preserving apply below.
// MONKEY (moon shadows): the NIGHT arm of the same one fetch — 1.0 (inert) by day, by feature-off
// and in the sun-down/moon-not-yet-up window. See `shadow_hook::realtime_shadow_terms`.
var player_shadow = 1.0;
var player_moon = 1.0;
let view_z = (view.view_from_world * in.world_position).z;
let shadow_cam_dist = distance(in.world_position.xyz, view.world_position.xyz);
let sun_lane_w = shadow_hook::sun_shadow_w(wow_light.fog_params.z);
let moon_lane_w = shadow_hook::moon_shadow_w(wow_light.fog_params.z);
#ifdef WOW_RIG_SKIN
// A skinned UNIT samples the map ONCE at its rig origin (between the feet), nudged 2.5 units
// TOWARD the sun, and dims uniformly — the reference's per-unit response. Per-fragment sampling
@@ -1815,24 +1820,30 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> WowFragOut {
wow_light.rig_origin[(fade_tag >> 19u) & 0x7ffu].xyz - 2.5 * sun_ray,
1.0,
);
player_shadow = shadow_hook::realtime_shadow(
let terms_rig = shadow_hook::realtime_shadow_terms(
anchor,
vec3<f32>(0.0, 1.0, 0.0),
view_z,
shadow_cam_dist,
wow_light.wmo_fog_params.z,
wow_light.fog_params.z,
sun_lane_w,
moon_lane_w,
);
player_shadow = terms_rig.x;
player_moon = terms_rig.y;
}
#else
player_shadow = shadow_hook::realtime_shadow(
let terms_frag = shadow_hook::realtime_shadow_terms(
in.world_position,
wow_normalize(in.world_normal),
view_z,
shadow_cam_dist,
wow_light.wmo_fog_params.z,
wow_light.fog_params.z,
sun_lane_w,
moon_lane_w,
);
player_shadow = terms_frag.x;
player_moon = terms_frag.y;
#endif
// The realtime map blocks only the directional sun. Preserve the authored ambient/probe
// contribution instead of multiplying the whole lighting result; the latter makes interiors,
@@ -1844,7 +1855,7 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> WowFragOut {
// arm keeps `worldShadows 0` (and a fully sunlit fragment) byte-identical: `ambient +
// (lit − ambient) × 1.0` is not guaranteed to round back to `lit`.
let shadow_term = mix(SHADOW_SUN_FLOOR, 1.0, player_shadow);
let lit_with_shadow = select(
var lit_with_shadow = select(
lit,
clamp(
wow_light.light_ambient.rgb + (lit - wow_light.light_ambient.rgb) * shadow_term,
@@ -1853,6 +1864,16 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> WowFragOut {
),
!is_interior && !is_rig && player_shadow < 0.999,
);
// MONKEY (moon shadows): the night sky includes ambient AND the directional/SH lobe.
// Scale its UNCLAMPED value; points join below, then the combine saturates. A saturated
// torch must never lose energy to the moon. Interiors/authored rigs and the exact off path
// retain their old expressions; no multiply-by-one round trip on daylight or strength zero.
if (player_moon < 1.0 && !is_interior && !is_rig) {
let sky = select(wow_light.light_ambient.rgb + wow_light.light_diffuse.rgb * ndotl,
sun_lobe, use_doodad_shade);
lit_with_shadow = sky * player_moon;
}
// Gamma-space albedo — the lane (0161): the buffer holds bytes, lighting math runs on the
// authored values. (The old fog_params.z linear-space A/B is dead — settled by the lane.)
// `m.tint` is the animated M2Color RGB (identity 1 for static batches) — the same per-batch
@@ -93,6 +93,33 @@ pub fn run(chain: &mut Chain, spell_id: u32) -> Result<()> {
}
}
}
// The AREA block (fields 11/12/13 — the DynamicObject machine, decision 0797): visual A is
// the persistent area model the dynobj instances verbatim (gated on field 11 ≠ 0), and the
// area kit's type-9 CharProc is the shard emitter. Printed because "what does Flamestrike
// actually put on the ground" is unanswerable from the five unit stages above — those are the
// CASTER's kit, and a ground effect's own model hangs off this row instead.
println!(
" area gate={} effect {:<5} -> {} kit {}",
stages.area_gate,
stages.area_effect,
visuals
.effect_path(stages.area_effect)
.unwrap_or("(none/MISSING PATH)"),
stages.area_kit,
);
if stages.area_kit != 0 {
match visuals.kit(stages.area_kit) {
Some(kit) => {
println!(
" areakit anim={:<12} sound={}",
kit.anim_id.map_or("—".into(), |a| a.to_string()),
kit.sound.map_or("—".into(), |s| s.to_string()),
);
crate::charprocs::print_kit_procs(&visuals, stages.area_kit, " ");
}
None => println!(" areakit {} (MISSING ROW)", stages.area_kit),
}
}
// The missile block (phase 4): the projectile exists whenever Speed > 0; its model is
// field 7's SpellVisualEffectName (else the ammo/ErrorCube fallbacks) and it homes to
// field 9's dest-attach ordinal on a live target.
+276 -1
View File
@@ -829,7 +829,7 @@ impl SpellLightKind {
/// After the vetoes the luminous keys run. Note `FIRE` catches `FIREWORK`, `FIRECRACKER`,
/// `SOULFIRE`, `HELLFIRE`, `RAINOFFIRE`, `FIREBALL`, `FIREBOLT` and `FIREBLAST` in one entry,
/// which is why the list is this short: the artists named the corpus by school already.
const SPELL_NAME_RULES: [(&str, SpellLightKind); 32] = [
const SPELL_NAME_RULES: [(&str, SpellLightKind); 33] = [
// --- the dark schools, and the name collisions they must win ---
("LIGHTNING", SpellLightKind::None),
("STARFIRE", SpellLightKind::None),
@@ -863,6 +863,19 @@ const SPELL_NAME_RULES: [(&str, SpellLightKind); 32] = [
("HOLY", SpellLightKind::Holy),
("DIVINE", SpellLightKind::Holy),
("SMITE", SpellLightKind::Holy),
// MONKEY (area spell light): the hunter's Flare. `Holy` names the LIGHT FAMILY, not the spell's
// school (Flare is school 0, Physical) — a magnesium signal flare burns white-hot, and Holy is
// this enum's warm-white. Without the rule its own ramp decides, and `spell_color`'s
// most-saturated key on `Flare_State_Base` is the `(1.00, 0.37, 0.08)` ember the particle COOLS
// to, not the white core it is born at, so the one spell in 1.12 that exists to be a light
// would light the ground campfire-orange.
//
// The key is anchored to a PATH SEPARATOR (`\FLARE_`) rather than the bare word, because the
// bare word over-reaches in this corpus: `SPELLS\Infernal_Flare_Rec` (a warlock summon) and
// `SPELLS\Missile_Flare` (a generic projectile streak) both contain `FLARE` and neither is a
// signal flare. Only `SPELLS\Flare_Cast_Base` / `SPELLS\Flare_State_Base` — the cast arc and
// the burning ground object — start a path component with it.
("\\FLARE_", SpellLightKind::Holy),
("FEL", SpellLightKind::Fel),
];
@@ -1112,6 +1125,129 @@ pub fn synthesize_spell_light<'a>(
})
}
// ---------------------------------------------------------------------------------------------
// MONKEY (area spell light): the PERSISTENT GROUND EFFECT arm of the spell route.
//
// A ground cast's visual is not the unit-kit lane at all: the server creates a TYPEID-6
// DynamicObject and the client instances the `SpellVisual` row's own area model on it for the
// object's whole life (`benilla_app::entities::dest_fx`). That lane is exactly the one the spell
// light had no shape for — it spawned a `Burst`, i.e. an impact flash, on something that stands on
// the ground for eight seconds, and then a shard flash 5× a second on top of it.
//
// The three questions this section answers for that lane, and why each one needed a rule of its
// own rather than reusing [`synthesize_spell_light`]'s answer:
//
// 1. **Does it light?** Half the area models author NO particle emitter whatsoever — they are flat
// animated ground DECALS (`Spells\Flamestrike_Impact_Base` and `spells\noname_area`, Explosive
// Trap's patch, are pure geometry). The emitter route returns `None` for them, so the burning
// ground of a Flamestrike could never light anything. [`area_light_kind`] is the fallback
// ladder for exactly that case.
// 2. **What colour?** See [`area_color`] — a POOL is read for seconds where a flash is read for a
// frame, and the holy ramps in this corpus are ember art.
// 3. **How far?** The wire `DYNAMICOBJECT_RADIUS` is the effect's real footprint (the shard
// emitter spreads its particles over exactly it), so an area light sizes its pool from the
// spell's own AoE rather than from its particles ([`area_reach`]).
// ---------------------------------------------------------------------------------------------
/// `Spell.dbc` field 1 `School` = **0, Physical** — a spell that has no magic school at all.
///
/// Called out because it is the one value that must NOT be read as "this school does not glow":
/// a hunter's Flare, a Volley of arrows and every trap are school 0, and the three want opposite
/// answers. School 0 therefore means "the column says nothing" ([`area_light_kind`]).
pub const SPELL_SCHOOL_PHYSICAL: u32 = 0;
/// The luminous family of a `Spell.dbc` **School** index (field 1 — an index, not a mask):
/// `1 Holy`, `2 Fire`, and the rest (Nature 3, Frost 4, Shadow 5, Arcane 6) dark.
///
/// This is the owner's rule read off the data instead of guessed off a filename, and it is why the
/// area lane needs almost no new name keys: Blizzard IS frost, Hurricane IS nature, Consecration IS
/// holy and Explosive Trap IS fire, stated by the same table the damage comes from. It is also
/// LOCALE-PROOF where matching the spell's NAME would not be — `Spell.dbc`'s name column is
/// translated, its school column is not.
///
/// No `Fel` arm: 1.12 has no fel school (a warlock's spells are Fire or Shadow), so green fire is
/// only ever named by a model path ([`SPELL_NAME_RULES`]'s `FEL`).
pub fn spell_school_kind(school: u32) -> SpellLightKind {
match school {
1 => SpellLightKind::Holy,
2 => SpellLightKind::Fire,
_ => SpellLightKind::None,
}
}
/// The luminous family of a persistent AREA effect — `model_path` is the area model the
/// DynamicObject instances, `spell_school` its spell's `Spell.dbc` school index, and `hue_kind` the
/// verdict [`synthesize_spell_light`] already reached on that model (`None` when it reached none,
/// i.e. no emitter or a vetoed one).
///
/// Three sources, most-authoritative first, and the order is the design:
///
/// 1. **The model's own path.** An artist who called the file `Flamestrike` or `FrostTrap` has
/// stated the answer, and stated it about the very object that will be on the ground. This arm
/// carries the VETOES too, so `Blizzard_Impact_Base` is dark here and can never be re-lit below.
/// 2. **The spell's school**, whenever it is a real one. This is what lets a model with a
/// meaningless name light correctly: Explosive Trap's patch is `spells\noname_area.m2`, which
/// names nothing and emits nothing, and only the Fire school says it burns.
/// 3. **The model's hue**, last, because it is the only guess in the list — and by the time we
/// reach it both the artist and the DBC have declined to answer.
pub fn area_light_kind(
model_path: &str,
spell_school: u32,
hue_kind: Option<SpellLightKind>,
) -> SpellLightKind {
if let Some(named) = spell_name_kind(model_path) {
return named;
}
if spell_school != SPELL_SCHOOL_PHYSICAL {
return spell_school_kind(spell_school);
}
hue_kind.unwrap_or(SpellLightKind::None)
}
/// An area light's colour: the model's own ramp ([`spell_color`], passed in as `ramp`) — **except
/// for the HOLY family, which always takes [`DEFAULT_HOLY`]**.
///
/// The exception is the area lane's alone, and it exists because a POOL is read differently from a
/// FLASH. Both holy ground effects in 1.12 author warm DUST: Consecration's strongest key is
/// `(1.00, 0.72, 0.17)` and Flare's is `(1.00, 0.37, 0.08)`. Taken literally, a paladin's
/// consecrated ground and a hunter's flare both put an orange pool on the terrain — which is
/// indistinguishable from Flamestrike's, for seconds at a time, right next to it. An impact keeps
/// its ramp (its whole life is a frame or two and the hue is the effect's signature); a light that
/// stands still long enough to be compared has to be the colour its school reads as.
pub fn area_color(kind: SpellLightKind, ramp: Option<[f32; 3]>) -> [f32; 3] {
match kind {
SpellLightKind::Holy => DEFAULT_HOLY,
_ => ramp.unwrap_or_else(|| kind.default_color()),
}
}
/// How high above the DynamicObject's own point an area light hangs (yd).
///
/// A dynobj sits ON the ground and its model is a flat decal, so a light at the object's exact
/// position is a light INSIDE the terrain: the faithful falloff `1/(0.7d + 0.03d²)` blows up at
/// `d → 0`, and every ground vertex within a step of the centre would clip to white while the ring
/// two yards out got nothing. Lifting it to roughly waist height makes the pool read as a pool.
pub const AREA_LIGHT_LIFT: f32 = 1.5;
/// The intensity an area light takes when its model authors **no usable emitter at all** — the
/// decal-only population (Flamestrike, Explosive Trap). `2.0` is the shared ladder's *brazier*
/// rung ([`spell_intensity`]), which is where an 8-yard patch of burning ground belongs: a torch
/// (1.5) is a hand-held flame and the 3.0 bonfire rung is reserved for a forge, per the spell
/// route's own "a spell must not out-blaze a forge".
pub const AREA_FALLBACK_INTENSITY: f32 = 2.0;
/// An area light's interior reach (yd) from the wire `DYNAMICOBJECT_RADIUS`.
///
/// `× 1.6` because the light has to spill PAST the effect: a pool that stopped exactly at the AoE
/// edge would draw a hard disc of lit terrain with a dark rim, where a real fire lights the ground
/// around it more faintly than the ground under it. The floor stops a 1-yard dynobj from producing
/// a light too small to notice, and the ceiling is the top rung of the shared reach ladder
/// (`room_claim::m2_light_reach`) — an area effect may reach as far as a bonfire and no further,
/// so a single spell can never out-light a building's own fixtures.
pub fn area_reach(radius: f32) -> f32 {
(radius * 1.6).clamp(8.0, 24.0)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1850,4 +1986,143 @@ mod tests {
assert_eq!(spell_intensity(0.5, 200.0), (2.0, "brazier"));
assert_eq!(spell_intensity(1.1, 500.0), (2.0, "brazier"));
}
// -- MONKEY (area spell light) ---------------------------------------------------------
/// Vanilla `Spell.dbc` school indices, so the area tests below read as the spells they are.
const PHYSICAL: u32 = 0;
const HOLY: u32 = 1;
const FIRE: u32 = 2;
const NATURE: u32 = 3;
const FROST: u32 = 4;
/// GOLDEN — the whole named worklist, on the REAL `SpellVisual` field-12 area models each spell
/// actually instances on its DynamicObject (dumped with `benilla-extract spellvis <id>`; the
/// `area` line). The third argument is what [`synthesize_spell_light`] independently reached on
/// that model, `None` for the decal-only half that authors no emitter at all — which is the
/// case the whole ladder exists for, and is Flamestrike's.
#[test]
fn the_ground_effects_that_light_and_the_ones_that_stay_dark() {
use SpellLightKind::{Fire as F, Holy as H, None as Dark};
// --- lights ---
// Flamestrike (2120): a flat animated burn decal — ZERO particle emitters, so the emitter
// route has nothing to say and the model's own name is the only source that does.
assert_eq!(
area_light_kind("Spells\\Flamestrike_Impact_Base.m2", FIRE, None),
F
);
// Rain of Fire (5740): five emitters, `FIRE` in the path — named and measured agree.
assert_eq!(
area_light_kind("Spells\\RainOfFire_Impact_Base.m2", FIRE, Some(F)),
F
);
// Consecration (26573): its one emitter ramps to `(1.00, 0.72, 0.17)`, which `spell_hue_kind`
// reads as FIRE. The school column is what corrects it — and the colour rule below is what
// stops the paladin's ground burning orange.
assert_eq!(
area_light_kind("spells\\consecration_impact_base.m2", HOLY, Some(F)),
H
);
// Explosive Trap (13812): the patch is `noname_area` — no emitters, and a filename that
// names nothing. ONLY the school says it burns.
assert_eq!(area_light_kind("spells\\noname_area.m2", FIRE, None), F);
// Flare (1543): school 0 Physical, so the school arm must abstain; the `\FLARE_` key is
// what makes the one spell that IS a light a light.
assert_eq!(
area_light_kind("spells\\flare_state_base.m2", PHYSICAL, Some(F)),
H
);
// --- dark, and each for its own reason ---
// Blizzard (10): `BLIZZ` vetoes in the path, and the school agrees. Its shards are this
// same model, so the whole storm stays dark.
assert_eq!(
area_light_kind("Spells\\Blizzard_Impact_Base.m2", FROST, None),
Dark
);
// Hurricane (16914): `LIGHTNING` in `LightningStorm_Cloud_State`.
assert_eq!(
area_light_kind("Spells\\LightningStorm_Cloud_State.m2", NATURE, None),
Dark
);
// Volley (1510): `ARCANE` in `arcaneshot_area` — and school 0, so the path veto is the
// ONLY thing holding it. That is the ordering being pinned.
assert_eq!(
area_light_kind("spells\\arcaneshot_area.m2", PHYSICAL, Some(F)),
Dark
);
// Frost Trap (13810), and Freezing Trap, which has no area object at all.
assert_eq!(
area_light_kind("spells\\frosttrap_aura.m2", FROST, None),
Dark
);
// A school veto beats a warm ramp: a frost model with one red spark stays dark even where
// its path says nothing.
assert_eq!(area_light_kind("spells\\noname_area.m2", FROST, Some(F)), Dark);
// …and with nothing to go on at all, dark is the answer (the reference's own behaviour).
assert_eq!(area_light_kind("spells\\noname_area.m2", PHYSICAL, None), Dark);
}
/// The `\FLARE_` key is anchored to a path separator so it takes the hunter's Flare and NOT the
/// two unrelated models that merely contain the word — the reason it is not the bare `FLARE`.
#[test]
fn the_flare_key_is_anchored_to_a_path_component() {
assert_eq!(
spell_name_kind("SPELLS\\Flare_State_Base.m2"),
Some(SpellLightKind::Holy)
);
assert_eq!(
spell_name_kind("SPELLS\\Flare_Cast_Base.m2"),
Some(SpellLightKind::Holy)
);
// A warlock summon and a generic projectile streak — neither is a signal flare, and both
// keep whatever their own art says.
assert_eq!(spell_name_kind("SPELLS\\Infernal_Flare_Rec.m2"), None);
assert_eq!(spell_name_kind("SPELLS\\Missile_Flare.m2"), None);
// `ArcaneConsecration_Impact_Base_Q` is the other collision in this corpus, and the veto
// block's position is what resolves it — `ARCANE` is read before any luminous key.
assert_eq!(
spell_name_kind("Spells\\ArcaneConsecration_Impact_Base_Q.m2"),
Some(SpellLightKind::None)
);
}
/// The colour rule: a fire pool keeps the artist's ramp, a holy pool never does.
#[test]
fn a_holy_pool_is_white_gold_whatever_its_dust_ramp_says() {
let ember = Some([1.0, 0.718, 0.173]);
assert_eq!(area_color(SpellLightKind::Fire, ember), [1.0, 0.718, 0.173]);
assert_eq!(area_color(SpellLightKind::Holy, ember), DEFAULT_HOLY);
// No ramp at all (the decal-only models) falls back to the family's own hue.
assert_eq!(area_color(SpellLightKind::Fire, None), DEFAULT_WARM);
assert_eq!(area_color(SpellLightKind::Fel, None), DEFAULT_FEL);
assert_eq!(area_color(SpellLightKind::Holy, None), DEFAULT_HOLY);
}
/// The reach ladder from the wire radius, and both clamps.
#[test]
fn area_reach_scales_the_wire_radius_between_its_clamps() {
// Flamestrike / Rain of Fire ship an 8 yd radius: the pool spills ~5 yd past the AoE.
assert!((area_reach(8.0) - 12.8).abs() < 1e-4);
assert!((area_reach(10.0) - 16.0).abs() < 1e-4);
// A tiny (or absent — the wire field is optional) radius still makes a visible pool…
assert_eq!(area_reach(0.0), 8.0);
assert_eq!(area_reach(2.0), 8.0);
// …and nothing reaches past the shared ladder's top rung.
assert_eq!(area_reach(20.0), 24.0);
assert_eq!(area_reach(500.0), 24.0);
}
/// The school column, read as the index it is. Only 1/2 light; 0 is "says nothing" and is
/// handled by [`area_light_kind`], not here.
#[test]
fn only_holy_and_fire_schools_glow() {
assert_eq!(spell_school_kind(0), SpellLightKind::None);
assert_eq!(spell_school_kind(1), SpellLightKind::Holy);
assert_eq!(spell_school_kind(2), SpellLightKind::Fire);
for dark in [3, 4, 5, 6, 99] {
assert_eq!(spell_school_kind(dark), SpellLightKind::None);
}
}
}
+8
View File
@@ -240,6 +240,14 @@ pub use fire_light::{synthesize_lamp_light, EmissiveBatch, LightRoute, Synthetic
// emitter, which the two above veto outright. Fire, holy and fel burn; frost, nature, arcane and
// shadow emit nothing at all.
pub use fire_light::{is_spell_light_path, synthesize_spell_light, SpellLightKind, SyntheticSpell};
// MONKEY (area spell light): the same route's PERSISTENT-GROUND-EFFECT arm — a DynamicObject's
// area model, which is routinely a flat decal with no emitter for the rule above to read, so its
// school comes off the model path / the spell's `Spell.dbc` school and its pool is sized from the
// wire AoE radius.
pub use fire_light::{
area_color, area_light_kind, area_reach, spell_school_kind, AREA_FALLBACK_INTENSITY,
AREA_LIGHT_LIFT,
};
// MONKEY (portal claims): which ROOMS one fixture may light -- containment, MOLR, and one portal
// hop. Shared by the runtime spawner and the `wmolights`/`wmolamps` audits so the gate the shader
// enforces and the gate the instrument prints can never be two different rules.
@@ -15,6 +15,7 @@
//! rebuilds, regardless of how many tiles/models are loaded or how fast the clock moves.
use benilla_formats::LiquidKind;
use bevy::ecs::system::SystemParam;
use bevy::prelude::*;
use bevy::render::extract_resource::{ExtractResource, ExtractResourcePlugin};
use bevy::render::render_resource::{Buffer, BufferDescriptor, BufferUsages};
@@ -35,7 +36,11 @@ use crate::view::WorldCamera;
/// `wow_effect.wgsl`. (`liquid.wgsl`/`wdl.wgsl` reuse the field NAMES but bind their own
/// per-material uniforms fed by `apply_wow_lighting` — editing this layout does NOT reach them.)
/// 0 light_ambient (w=Mod2x 1.0) · 1 light_diffuse (w=clamp on) · 2 light_sun (w=dir/SH enable) ·
/// 3 light_spec (w=terrain shininess 20) · 4 fog_color (w=enable) · 5 fog_params (x=start y=end w=farclip) ·
/// 3 light_spec (w=terrain shininess 20) · 4 fog_color (w=enable) · 5 fog_params (x=start y=end
/// w=farclip; MONKEY (moon shadows) `.z` = the SIGNED directional-shadow weight — `+sun_w`
/// by day, `−(moon weight × moonShadowStrength)` at night, 0 when neither body casts. The
/// two are mutually exclusive by [`moon_shadow_weight`], so one float carries both; see
/// [`pack_shadow_lane`]) ·
/// 6-8 sh_c10_{r,g,b} · 9-11 sh_c13_{r,g,b} · 12 sh_c16 (.w = the world-shadow flag in the
/// integer part, MONKEY (bake floor) `interiorBakeFloor × interiorGain` in the fraction —
/// [`BAKE_LANE_SCALE`]) ·
@@ -603,6 +608,28 @@ impl Default for ShadowDistance {
}
}
/// MONKEY (moon shadows): how dark a MOON-shadowed fragment gets at night (`moonShadowStrength`,
/// 0..1, default 0.35), bridged from benilla-app's settings registry exactly as [`ShadowDistance`]
/// is. `0.35` means a fully moon-shadowed fragment keeps `1 − 0.35 = 65 %` of the night sky (ambient + directional)
/// term — a hint of a silhouette, not a daylight-hard shadow.
///
/// A dial rather than a constant because the honest answer to "how much light does a clear full moon
/// throw" is *very little* (the reference client casts none at all), and the interesting range —
/// "just enough that the world is not flat" to "stylised moonlight" — is entirely a taste question
/// that wants to be answered with the scene on screen.
///
/// **`0` is the faithful null.** Every consumer guards on it (the packer refuses to hand the moon a
/// non-zero weight, and the receivers keep their existing `< 0.999` early-out arm), so a night at
/// `moonShadowStrength = 0` renders bit-identically to the build before this feature existed.
#[derive(Resource, Clone, Copy, PartialEq, Debug)]
pub struct MoonShadowStrength(pub f32);
impl Default for MoonShadowStrength {
fn default() -> Self {
Self(0.35)
}
}
/// MONKEY (sun shadow perf): the live `shadowFilter` choice, bridged from benilla-app's shadow rig
/// and extracted into the RENDER world.
///
@@ -675,6 +702,9 @@ pub(super) fn register(app: &mut App) {
.init_resource::<RoomClaimTable>()
.init_resource::<WorldShadowActive>()
.init_resource::<ShadowDistance>()
// MONKEY (moon shadows): the night directional-shadow strength dial (0 = today's render).
.init_resource::<MoonShadowStrength>()
.init_resource::<ShadowHandover>()
// MONKEY (sun shadow perf): the live PCF choice, extracted for `static_gx`'s hand-rolled
// pipeline (every Bevy-material receiver keys off the view component instead).
.init_resource::<ShadowFilterGaussian>()
@@ -761,11 +791,170 @@ pub(super) fn per_frame_blob_bytes() -> u64 {
/// MONKEY (night fade): realtime-shadow day strength from the celestial sun's height
/// (`sin(elevation)`): 0 at or below the horizon, ramping to 1 by ~12° so shadows fade out at dusk
/// and in at dawn. A smoothstep for a soft knee rather than a hard switch at the horizon.
fn sun_shadow_strength(sun_height: f32) -> f32 {
/// MONKEY (moon shadows): `pub` because the SHADOW RIG needs the same verdict the packer reaches.
/// The rig (`benilla_app::shadow_core::manage_rig`) re-aims the one directional light at the moon
/// exactly when this is 0, and the packer hands the moon a weight exactly when this is 0 — two
/// copies of that threshold would be two chances to disagree about which body the map holds.
/// (`blob_shadow.rs` keeps its own three-line mirror, pinned by its own test; that one predates
/// this export and is deliberately self-contained.)
pub fn sun_shadow_strength(sun_height: f32) -> f32 {
let t = (sun_height / 0.208).clamp(0.0, 1.0); // 0.208 ≈ sin(12°)
t * t * (3.0 - 2.0 * t)
}
/// MONKEY (moon shadows): the NIGHT half of the one shadow rig's HAND-OVER LAW, in `[0,1]`.
///
/// `sun_height` / `moon_height` are `celestial_dir.y` / `moon_dir.y` — the SINE of each body's
/// elevation, Bevy space. Returns how much the moon is allowed to cast, BEFORE
/// [`MoonShadowStrength`] scales it.
///
/// **Why a hard gate and not a crossfade.** benilla's receivers sample ONE shadow-mapped
/// directional light (the loop in `shadow_hook.wgsl` ASSIGNS — last light wins), so at any instant
/// the map holds exactly one body's depth. A weight that overlapped the sun's would be a weight
/// applied to the WRONG body's map for the duration of the overlap. So the moon's weight is
/// strictly zero while the sun still has any, and the rig re-aims in the window where BOTH are
/// zero — `sun_shadow_strength` is exactly 0 at and below the horizon, and this is exactly 0 until
/// the moon clears the same 12° ramp.
///
/// **The gate never actually clips anything**, which is what makes it continuous rather than a
/// step: with the shipped `DayNight` tables the celestial sun crosses the horizon at ≈20:30 and the
/// white moon does not clear it until ≈22:17, so `sun_w` and the moon's elevation ramp are never
/// both positive (asserted over the whole game day by
/// `the_two_shadow_weights_never_overlap_and_neither_jumps`). The gate is therefore an INVARIANT
/// guard, not a shaping term: it keeps the single-light rule true even if a zone or a retuned table
/// ever put the two in the sky together.
///
/// The elevation ramp is [`sun_shadow_strength`]'s own curve, evaluated on the moon's height —
/// deliberately the same smoothstep over the same 0..sin(12°) band, so a body low on the horizon
/// casts nothing (which is also what keeps a moonrise shadow from stretching to infinity; the RIG
/// clamps the basis at 18° for the same reason, `shadow_core::MIN_SHADOW_SUN_ELEVATION`).
pub fn moon_shadow_weight(sun_height: f32, moon_height: f32) -> f32 {
if sun_shadow_strength(sun_height) > 0.0 {
return 0.0;
}
sun_shadow_strength(moon_height)
}
/// MONKEY (moon shadows): one map, one acknowledged body. The app requests the current clock's
/// body before transform propagation, acknowledges an actual Transform write, then holds zero
/// through the next frame. Both receivers and blobs read `weight`, never the desired clock weight.
/// This makes a time jump / midnight enable as safe as the naturally dark dawn/dusk interval.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ShadowBody {
Sun,
Moon,
}
#[derive(Resource, Default)]
pub struct ShadowHandover {
pub aimed: Option<ShadowBody>,
pub wanted: Option<ShadowBody>,
pub weight: f32,
settling: bool,
ramp: f32,
}
impl ShadowHandover {
/// Called ONCE per frame, after lighting resolve and the settings bridge. The rig runs before
/// Propagate; an acknowledgement from its preceding invocation has therefore propagated now.
pub fn request(&mut self, sun: f32, moon: f32, strength: f32, active: bool, dt: f32) {
let wanted = active.then_some(if strength > 0.0 && sun <= 0.0 {
ShadowBody::Moon
} else {
ShadowBody::Sun
});
if wanted != self.wanted || !active {
self.wanted = wanted;
self.ramp = 0.0;
self.weight = 0.0;
}
if !active {
self.aimed = None;
self.settling = false;
return;
}
if self.aimed != wanted {
self.weight = 0.0;
return;
}
if self.settling {
self.settling = false;
self.weight = 0.0;
return;
}
// Fixed envelope: 1.5 seconds from zero to full, independent of frame rate/strength.
self.ramp = (self.ramp + dt.max(0.0) / 1.5).min(1.0);
self.weight = match wanted {
Some(ShadowBody::Sun) => pack_shadow_lane(sun * self.ramp, 0.0),
Some(ShadowBody::Moon) => pack_shadow_lane(0.0, moon * strength.clamp(0.0, 1.0) * self.ramp),
None => 0.0,
};
}
/// Only the rig may acknowledge; choosing a body without writing a transform is not ready.
pub fn aim_written(&mut self, body: ShadowBody) {
if self.aimed != Some(body) {
self.aimed = Some(body);
self.settling = true;
self.ramp = 0.0;
self.weight = 0.0;
}
}
}
/// MONKEY (moon shadows): the three realtime-shadow dials [`build_light_data`] packs, as ONE
/// system param.
///
/// A bundle rather than three `Res` arguments because the packer had reached Bevy's **16-param
/// ceiling** — `MoonShadowStrength` was the seventeenth, and the failure is not a helpful one
/// (`.chain()` reports that a trait bound is unsatisfied on the system TUPLE, naming neither the
/// system nor the limit). Grouping them is also the honest shape: all three are bridged from the
/// app's shadow rig, all three ride packed lanes, and a fourth shadow dial now has somewhere to go
/// that does not cost the next reader an afternoon.
#[derive(SystemParam)]
pub(super) struct ShadowLanes<'w> {
/// MONKEY (world shadows): the `worldShadows` lane flag, packed into `sh_c16.w` for the MCSH gate.
world_active: Res<'w, WorldShadowActive>,
/// MONKEY (distance slider): the realtime-shadow render distance, packed for the edge fade.
distance: Res<'w, ShadowDistance>,
/// MONKEY (moon shadows): the acknowledged signed weight, shared with the rig and blob.
handover: Res<'w, ShadowHandover>,
}
/// MONKEY (moon shadows): the CPU half of the `fog_params.z` pack — ONE SIGNED lane carrying both
/// directional-shadow weights.
///
/// **There is still no free f32** ([`LightStd430`] is 8528 B, pinned by tests and mirrored by three
/// shaders plus the portrait booth — see [`DAYLIGHT_LANE_SCALE`] for the full accounting). The
/// daylight floor took `wmo_fog_params.w`'s fraction and the bake floor took `sh_c16.w`'s, so the
/// two lanes with spare RANGE are spent. This one needs neither: the hand-over law above guarantees
/// the two weights are **never both non-zero**, so they can share a single lane by SIGN — the
/// cheapest possible packing, exact in both directions, with no quantisation and no cliff.
///
/// `+w` = the sun's weight, `-w` = the moon's (already scaled by [`MoonShadowStrength`]), `0` =
/// neither (the ≈20:30-22:17 window when the sun has set and the moon has not risen — and every
/// frame of a build with `moonShadowStrength 0`).
///
/// **The shader end** decodes with `max(z, 0)` / `max(-z, 0)` (`shadow_hook.wgsl`'s `sun_shadow_w`
/// / `moon_shadow_w`). Every pre-existing reader of this lane is either one of those two calls or
/// already clamped — `clamp(1 - z, 0, 1)` (the three `ext_night_w` decodes) returns 1 for any
/// negative `z`, which is the same 1 it returned for the 0 that used to be there, so the exterior
/// torch lane is bit-identical under the new sign. **Keep in sync with those two WGSL functions.**
pub fn pack_shadow_lane(sun_w: f32, moon_w: f32) -> f32 {
debug_assert!(
!(sun_w > 0.0 && moon_w > 0.0),
"the shadow rig holds ONE body's map: sun {sun_w} and moon {moon_w} cannot both cast"
);
sun_w - moon_w
}
/// MONKEY (moon shadows): the SHADER's two decodes, transcribed. Only exist for the round-trip test
/// below; the real decodes live in `shadow_hook.wgsl`.
#[cfg(test)]
fn unpack_shadow_lane(w: f32) -> (f32, f32) {
(w.max(0.0), (-w).max(0.0))
}
/// MONKEY (enclosed day floor): how [`DynamicInteriors::daylight`] rides to the shader — as the
/// FRACTIONAL part of the packed `wmo_fog_params.w` lane, `w = 1 + debug + daylight * this`.
///
@@ -1178,10 +1367,8 @@ fn build_light_data(
// disagree about which entry is which.
mut claims: ResMut<RoomClaimTable>,
time: Res<Time>,
// MONKEY (world shadows): the `worldShadows` lane flag, packed into `sh_c16.w` for the MCSH gate.
world_shadow: Res<WorldShadowActive>,
// MONKEY (distance slider): the realtime-shadow render distance, packed for the edge fade.
shadow_distance: Res<ShadowDistance>,
// The three realtime-shadow dials, bundled (see [`ShadowLanes`]).
shadow: ShadowLanes,
// MONKEY (dynamic interiors): the interior lane's on/off + live knobs, packed for `static_gx.wgsl`.
dynamic_interiors: Res<DynamicInteriors>,
// MONKEY (fire GO lights): the live gain on synthesised fire lights (0 = the lane off).
@@ -1264,7 +1451,7 @@ fn build_light_data(
// time in exactly this way, three rows down). See [`BAKE_LANE_SCALE`] for why a fraction is
// invisible to the one `> 0.5` decode this lane has, and why the product is clamped first.
rows[12][3] = pack_bake_lane(
world_shadow.0,
shadow.world_active.0,
dynamic_interiors.bake_floor,
dynamic_interiors.interior_gain,
);
@@ -1274,11 +1461,14 @@ fn build_light_data(
// sun still casts the right DIRECTION.
// MONKEY (darkness gains): computed once above — `nightGain` rides this exact same curve, so
// the dim and the shadow fade can never drift onto two different dusk clocks.
rows[5][2] = sun_w;
// MONKEY (moon shadows): pack the rig's ACKNOWLEDGED weight, not the clock's desired one.
// The body may have changed abruptly this frame; the shared state holds zero until the new
// basis has propagated and then ramps. One signed float still keeps the buffer at 8528 bytes.
rows[5][2] = shadow.handover.weight;
// MONKEY (distance slider): the realtime-shadow render distance (yd), packed into the free
// `_wmo_fog[1].z` / `wmo_fog_params.z` lane (row 19). The receivers' edge fade reads it so the
// shadow fades at the cascade's actual `maximum_distance`, whatever the slider is set to.
rows[19][2] = shadow_distance.0;
rows[19][2] = shadow.distance.0;
// MONKEY (dynamic interiors): the lane's on/off into the free `wmo_fog_params.w` lane (1 = WMO
// interior surfaces + props light from the room's live fixtures, 0 = the faithful baked path).
// Its three knobs ride `point_count.yzw`, packed with the table below.
@@ -1745,6 +1935,79 @@ mod tests {
}
}
// MONKEY (moon shadows): a CPU mirror of the receivers' order, including the exact off arm.
fn moon_combine(sky: f32, point: f32, strength: f32, shadow: f32) -> f32 {
let factor = 1.0 - strength * (1.0 - shadow);
if factor < 1.0 {
(sky * factor + point).clamp(0.0, 1.0)
} else {
(sky + point).clamp(0.0, 1.0)
}
}
#[test]
fn moon_shadows_preserve_saturated_points_and_attenuate_the_whole_sky() {
assert_eq!(moon_combine(0.2, 1.0, 0.35, 0.0), 1.0);
assert_eq!(moon_combine(0.2, 0.0, 0.35, 0.0), 0.2 * (1.0 - 0.35));
for (sky, point) in [(0.2f32, 0.0f32), (0.2, 1.0), (1.2, 0.1)] {
assert_eq!(moon_combine(sky, point, 0.0, 0.0).to_bits(),
(sky + point).clamp(0.0, 1.0).to_bits());
assert_eq!(moon_combine(sky, point, 0.35, 1.0).to_bits(),
(sky + point).clamp(0.0, 1.0).to_bits());
}
}
// MONKEY (moon shadows): exercise the REAL state machine, including delayed acknowledgement
// (new rig's deferred spawn), the propagation hold, and frame-rate-independent monotone ramp.
fn assert_shadow_transition(state: &mut ShadowHandover, body: ShadowBody, strength: f32) {
let (sun, moon) = if body == ShadowBody::Sun { (1.0, 0.0) } else { (0.0, 1.0) };
for _ in 0..3 {
state.request(sun, moon, strength, true, 1.0 / 60.0);
assert_eq!(state.weight, 0.0, "no acknowledgement: wrong/absent basis must not cast");
}
state.aim_written(body);
assert_eq!(state.weight, 0.0, "transform-write frame");
state.request(sun, moon, strength, true, 1.0 / 60.0);
assert_eq!(state.weight, 0.0, "propagation hold frame");
let mut previous = 0.0;
for _ in 0..91 {
state.request(sun, moon, strength, true, 1.0 / 60.0);
assert_eq!(state.aimed, state.wanted);
assert!(state.weight.abs() >= previous, "monotone hand-over ramp");
assert!((state.weight.abs() - previous) <= 1.0 / 90.0 + 1e-6);
previous = state.weight.abs();
}
assert_eq!(state.weight, if body == ShadowBody::Sun { 1.0 } else { -strength });
}
#[test]
fn moon_shadow_clock_jumps_wait_for_the_right_basis() {
let mut state = ShadowHandover::default();
assert_shadow_transition(&mut state, ShadowBody::Sun, 0.35);
assert_shadow_transition(&mut state, ShadowBody::Moon, 0.35); // noon -> midnight
assert_shadow_transition(&mut state, ShadowBody::Sun, 0.35); // midnight -> noon
}
#[test]
fn moon_shadow_midnight_login_and_enable_wait_for_the_rig() {
let mut state = ShadowHandover::default();
assert_shadow_transition(&mut state, ShadowBody::Moon, 0.35); // login at midnight
state.request(0.0, 1.0, 0.35, false, 1.0);
assert_eq!(state.weight, 0.0);
assert_shadow_transition(&mut state, ShadowBody::Moon, 0.35); // enable rig at midnight
}
#[test]
fn moon_shadow_strength_zero_to_enabled_waits_for_the_moon() {
let mut state = ShadowHandover::default();
state.request(0.0, 1.0, 0.0, true, 0.0);
state.aim_written(ShadowBody::Sun);
state.request(0.0, 1.0, 0.0, true, 0.0);
state.request(0.0, 1.0, 0.0, true, 2.0);
assert_eq!(state.weight, 0.0);
assert_shadow_transition(&mut state, ShadowBody::Moon, 0.35);
}
/// GOLDEN — the **live exterior M2 response** (0803): `wow_model.wgsl`'s doodad/entity lane must
/// reproduce `E = A + I·D·(4/17)(0.375 + 2μ + 1.875μ²)` off the rows [`pack_model_core_rows`]
/// writes, with `A` NOT scaling by the per-instance intensity and every sun band scaling by it
@@ -1885,6 +2148,9 @@ mod tests {
.init_resource::<Time>()
.init_resource::<WorldShadowActive>()
.init_resource::<ShadowDistance>()
// MONKEY (moon shadows): the packer's sixth dial resource.
.init_resource::<MoonShadowStrength>()
.init_resource::<ShadowHandover>()
.init_resource::<DynamicInteriors>()
.init_resource::<FireLightGain>()
.init_resource::<SpellLightGain>()
@@ -2595,6 +2861,9 @@ mod tests {
celestial_dir: Vec3::new(0.0, celestial_y, 0.0),
..base
});
// MONKEY (moon shadows): this gain test models an already-settled sun rig; the
// packer now consumes its publication instead of manufacturing a clock-only weight.
app.world_mut().resource_mut::<ShadowHandover>().weight = sun_shadow_strength(celestial_y);
// An EXTERIOR fire, to prove the dial stops at the sky law.
app.world_mut().spawn((
crate::terrain_stream::point_light([1.0, 0.5, 0.25], 2.0),
@@ -2627,6 +2896,194 @@ mod tests {
assert!((night.points[1][0] - 2.0).abs() < 1e-4, "…at its authored value");
}
/// GOLDEN — MONKEY (moon shadows): THE HAND-OVER LAW, swept over the whole game day against
/// the REAL `DayNight` tables.
///
/// Four properties, and each one is a way the feature breaks in a manner a screenshot cannot
/// diagnose:
///
/// 1. **Never both.** benilla has exactly ONE shadow-mapped directional light (the receivers
/// ASSIGN over the light loop — last one wins), so the map holds one body's depth at a time.
/// Two non-zero weights at any minute would mean one of them was being applied to the OTHER
/// body's map, which renders as a shadow pointing the wrong way rather than as an error.
/// 2. **The gate never clips.** The moon's weight is hard-gated to zero while the sun has any
/// ([`moon_shadow_weight`]), and a hard gate biting on a non-zero value would be a STEP.
/// This asserts the gate is inert with the shipped tables — the moon's own elevation ramp is
/// already 0 everywhere the sun's is positive — so the gate is an invariant guard, not a
/// shaping term.
/// 3. **Continuity.** No minute-to-minute jump in either weight, at dusk or at dawn. The
/// threshold is deliberately loose (0.1 per game minute): the point is "no pop", not a
/// derivative bound, and the smoothsteps are far smoother than that.
/// 4. **The window is real and it is the blob's.** Between the sun's set and the moon's rise
/// NEITHER casts. That is not a bug to be crossfaded away — the moon is genuinely under the
/// horizon then — and the oval blob covers it at full strength (`blob_shadow::blob_weight`
/// of a zero lane is 1.0). The test pins the window's existence so a future table edit that
/// closed or inverted it has to say so here.
#[test]
fn the_two_shadow_weights_never_overlap_and_neither_jumps() {
let sample = |minute: f32| {
let sun_h = super::super::daynight::celestial_sun_direction(minute).y;
let moon_h = super::super::daynight::moon_direction(minute).y;
(
sun_shadow_strength(sun_h),
moon_shadow_weight(sun_h, moon_h),
// The moon's ramp BEFORE the gate — property 2 needs to see what the gate hid.
sun_shadow_strength(moon_h),
)
};
let (mut prev_sun, mut prev_moon, _) = sample(0.0);
let (mut dark_minutes, mut moon_minutes, mut sun_minutes) = (0u32, 0u32, 0u32);
for m in 1..=1440 {
let (sun, moon, ungated) = sample(m as f32);
assert!(
!(sun > 0.0 && moon > 0.0),
"minute {m}: both bodies cast (sun {sun}, moon {moon}) — the rig holds ONE map"
);
assert!(
!(sun > 0.0 && ungated > 0.0),
"minute {m}: the moon's elevation ramp ({ungated}) is live while the sun's is \
({sun}) — the hand-over gate is now CLIPPING a non-zero value, i.e. it has become \
a step in the weight rather than a guard on the invariant"
);
assert!(
(sun - prev_sun).abs() < 0.1,
"minute {m}: the sun weight jumped {prev_sun} -> {sun}"
);
assert!(
(moon - prev_moon).abs() < 0.1,
"minute {m}: the moon weight jumped {prev_moon} -> {moon}"
);
if sun > 0.0 {
sun_minutes += 1;
} else if moon > 0.0 {
moon_minutes += 1;
} else {
dark_minutes += 1;
}
prev_sun = sun;
prev_moon = moon;
}
assert!(sun_minutes > 600, "the sun should cast most of the day: {sun_minutes} min");
assert!(moon_minutes > 200, "the moon should cast most of the night: {moon_minutes} min");
assert!(
dark_minutes > 60,
"the sun sets ~1h45m before the moon rises — the oval blob owns that window, and a \
zero here would mean the two bodies had been made to overlap: {dark_minutes} min"
);
}
/// MONKEY (moon shadows): the SHADER end of the signed lane, asserted against the shader TEXT.
///
/// [`pack_shadow_lane`] and `shadow_hook.wgsl`'s `sun_shadow_w` / `moon_shadow_w` are two
/// declarations of ONE encoding, and the failure mode of a drift is not a build error: a
/// receiver that went back to reading `fog_params.z` raw would take a NEGATIVE sun weight at
/// night and BRIGHTEN every shadowed fragment (`1 − occ·negative > 1`), which reads as a
/// glowing patch under a tree, not as a broken decode. `static_gx.wgsl` is this crate's own
/// receiver, so it is the copy a `benilla-world` test can see; `terrain.wgsl` and
/// `wow_model.wgsl` are `benilla-assets`' and are covered by the naga probes.
#[test]
fn the_static_gx_receiver_decodes_the_signed_shadow_lane() {
let src = include_str!("../shaders/static_gx.wgsl");
for call in [
"shadow_hook::sun_shadow_w(wow_light.fog_params.z)",
"shadow_hook::moon_shadow_w(wow_light.fog_params.z)",
] {
assert!(
src.contains(call),
"static_gx.wgsl no longer decodes the signed shadow lane through `{call}` — a raw \
read of `fog_params.z` takes the MOON's negative weight as the sun's and brightens \
what it should darken"
);
}
// …and the moon arm is still behind its early-out, which is the whole `moonShadowStrength 0`
// ⇒ bit-identical-night contract on this receiver.
assert!(
src.contains("if (world_moon < 1.0) {"),
"static_gx.wgsl's moon arm lost its `world_moon < 1.0` guard — every daylight and \
feature-off fragment now pays for (and may round through) the night subtraction"
);
}
/// GOLDEN — MONKEY (moon shadows): the SIGNED `fog_params.z` pack, both ends.
///
/// The lane is one float carrying two mutually-exclusive weights by sign, so the round trip has
/// to be exact in both directions — a lossy pack here is a shadow that is silently the wrong
/// strength, or (worse) a moon weight leaking into the day arm. [`unpack_shadow_lane`] is the
/// transcription of `shadow_hook.wgsl`'s two decodes; if the WGSL and this drift, the assertion
/// that a moon frame reads zero on the sun's decode is the one that fires.
#[test]
fn the_signed_shadow_lane_round_trips_both_weights() {
for sun in [0.0f32, 0.25, 0.5, 1.0] {
assert_eq!(unpack_shadow_lane(pack_shadow_lane(sun, 0.0)), (sun, 0.0));
}
for moon in [0.0f32, 0.12, 0.35, 1.0] {
assert_eq!(unpack_shadow_lane(pack_shadow_lane(0.0, moon)), (0.0, moon));
}
// The pre-feature bits: a zero moon weight leaves the lane EXACTLY the sun's own value, so
// a `moonShadowStrength 0` build packs the float it always packed.
assert_eq!(pack_shadow_lane(1.0, 0.0), 1.0);
assert_eq!(pack_shadow_lane(0.0, 0.0), 0.0);
}
/// GOLDEN — MONKEY (moon shadows): the dial's two ends, through the REAL packer.
///
/// `moonShadowStrength 0` must pack the byte-identical night frame the build before this
/// feature packed — that is the whole "guard it like the existing `world_shadow < 0.999` arm"
/// contract, and it is what stands between a faint moon shadow and a renderer whose night rows
/// no longer match three rounds of shading forensics. The non-zero arm then proves the strength
/// is folded CPU-side (so the receivers carry no extra uniform) and lands NEGATIVE, where the
/// sun's own decode (`max(z, 0)`) reads zero out of it.
#[test]
fn the_moon_strength_dial_is_packed_cpu_side_and_zero_restores_the_old_night() {
// Midnight: the sun is 10 degrees under, the white moon is overhead (+55 degrees).
let sun_h = super::super::daynight::celestial_sun_direction(0.0).y;
let moon_h = super::super::daynight::moon_direction(0.0).y;
assert!(sun_h < 0.0 && moon_h > 0.3, "midnight: sun down, moon high ({sun_h}, {moon_h})");
let pack = |strength: f32| {
let mut app = packer_app();
app.world_mut().insert_resource(WowLighting {
celestial_dir: Vec3::new(0.0, sun_h, 0.0),
moon_dir_white: Vec3::new(0.0, moon_h, 0.0),
..default()
});
app.world_mut().insert_resource(MoonShadowStrength(strength));
let mut handover = app.world_mut().resource_mut::<ShadowHandover>();
handover.request(0.0, 1.0, strength, true, 0.0);
handover.aim_written(if strength > 0.0 { ShadowBody::Moon } else { ShadowBody::Sun });
handover.request(0.0, 1.0, strength, true, 0.0);
handover.request(0.0, 1.0, strength, true, 1.5);
app.update();
app.world().resource::<WowLightData>().0
};
let off = pack(0.0);
assert_eq!(
off.rows[5][2], 0.0,
"moonShadowStrength 0 must pack the pre-feature night lane, to the bit"
);
let on = pack(MoonShadowStrength::default().0);
assert_eq!(
on.rows[5][2], -0.35,
"a high moon at the shipped strength packs the FULL weight, negated"
);
assert_eq!(
unpack_shadow_lane(on.rows[5][2]),
(0.0, 0.35),
"the sun's decode reads zero out of a moon frame"
);
// Every other row is the same night frame either way — the dial reaches ONE lane.
for row in 0..LIGHT_HEADER_ROWS {
for c in 0..4 {
if (row, c) == (5, 2) {
continue;
}
assert_eq!(
on.rows[row][c], off.rows[row][c],
"row {row}.{c} moved with moonShadowStrength — the dial owns ONE lane"
);
}
}
}
/// GOLDEN — MONKEY (darkness gains): `interiorGain` scales all THREE of the room lane's inputs
/// and nothing on the exterior lane.
///
+24
View File
@@ -41,6 +41,13 @@ pub use global_light::{
ShadowDistance, ShadowFilterGaussian, ShadowProxyLight, SyntheticFireLight,
WorldShadowActive,
};
// MONKEY (moon shadows): the night directional-shadow strength dial (`moonShadowStrength`), the
// one resource the settings registry writes for this feature (`0` = the pre-feature night render),
// plus the two halves of the hand-over law the SHADOW RIG has to agree with the light packer
// about — which body the one directional light is aimed at, and how much it may cast.
pub use global_light::{
moon_shadow_weight, sun_shadow_strength, MoonShadowStrength, ShadowBody, ShadowHandover,
};
// MONKEY (merge 2026-09-17): upstream's world-light component, adopted as THE world light.
pub use global_light::WorldPointLight;
// MONKEY (spellLightGain): the spell lane's marker + its live gain — the two-word world-side
@@ -204,6 +211,23 @@ impl WowLighting {
self.celestial_dir
}
/// MONKEY (moon shadows): the visible **white moon** direction (camera→moon, Bevy space) — the
/// body the one shadow rig re-aims at after dark.
///
/// This is the renderer's REAL moon, not a stand-in for the sun: [`daynight::moon_direction`]
/// is its own `DayNight` track pair off `WoW.exe`'s sky-bodies builder (elevation table
/// `0xce8d24`, φ 35°↔100° — overhead at midnight, parked 10° under the horizon 04:00→22:00).
/// Its AZIMUTH, however, is the constant 45° the celestial sun also uses (table `0xce8d0c`), so
/// a moon shadow falls along the same compass line a midday sun shadow does, only from a lower
/// and slower-moving elevation. That is the reference's own geometry, not an approximation
/// here.
///
/// Exposed beside [`Self::celestial_dir`] and for the same reason: the shadow rig lives in
/// `benilla-app` and must aim at the same body the light packer is weighting.
pub fn moon_dir(&self) -> Vec3 {
self.moon_dir_white
}
/// Per-kind **water swatch endpoints**: `(shallow_rgb, deep_rgb, shallow_alpha, deep_alpha)`. These
/// are the ENDPOINTS; the ramp between them is a 64-row byte-space accumulator that `liquid.wgsl`
/// reproduces (`swatch_row`), not the plain lerp this doc used to describe — it stops one row short
@@ -1459,18 +1459,25 @@ fn fragment(in: GxVsOut) -> @location(0) vec4<f32> {
// fade, via `benilla::shadow_hook`) — now with the same fades as terrain + models. INTERIOR
// batches (`WORD_INTERIOR`) take the point-light (torch) shadow instead (#2): no sun reaches a
// sealed room, but a promoted torch does. Both are no-ops when their light source is absent.
// MONKEY (moon shadows): the NIGHT arm rides the SAME fetch and the SAME `WORD_INTERIOR` gate —
// 1.0 (inert) on every interior batch, all day, and with the feature off. Interiors stay
// untouched at both ends of the clock: a sealed room has no sky, so it has no moon either.
var world_shadow = 1.0;
var world_moon = 1.0;
if ((in.word & WORD_INTERIOR) == 0u) {
let view_z = (view.view_from_world * in.world_position).z;
let cam_dist = distance(in.world_position.xyz, view.world_position.xyz);
world_shadow = shadow_hook::realtime_shadow(
let terms = shadow_hook::realtime_shadow_terms(
in.world_position,
n_lit,
view_z,
cam_dist,
wow_light.wmo_fog_params.z,
wow_light.fog_params.z,
shadow_hook::sun_shadow_w(wow_light.fog_params.z),
shadow_hook::moon_shadow_w(wow_light.fog_params.z),
);
world_shadow = terms.x;
world_moon = terms.y;
} else {
world_shadow = shadow_hook::torch_shadow(in.world_position, n_lit);
}
@@ -1480,7 +1487,7 @@ fn fragment(in: GxVsOut) -> @location(0) vec4<f32> {
// `world_shadow < 0.999` arm keeps `worldShadows 0` (no shadow-mapped light) and a fully
// sunlit fragment byte-identical: `ambient + (lit − ambient) × 1.0` is not guaranteed to
// round back to `lit`.
let lit_nl_shadowed = select(
var lit_nl_shadowed = select(
lit_nl,
clamp(
wow_light.light_ambient.rgb + (lit_nl - wow_light.light_ambient.rgb) * shadow_term,
@@ -1489,6 +1496,14 @@ fn fragment(in: GxVsOut) -> @location(0) vec4<f32> {
),
world_shadow < 0.999,
);
// MONKEY (moon shadows): shadow the unclamped exterior SKY, ambient AND directional.
// Points join later, before the final saturation, so a hot torch stays hot beneath a canopy.
// world_moon is exactly one indoors and when off; leave those arithmetic paths untouched.
if (world_moon < 1.0) {
lit_nl_shadowed = (wow_light.light_ambient.rgb + wow_light.light_diffuse.rgb * ndotl)
* world_moon;
}
// The order-2 SH basis products over the fragment normal — shared by the exterior
// doodad lobe and the interior-prop probe lane (wow_model.wgsl computes them once too).
let quad = vec4<f32>(n_lit.x * n_lit.y, n_lit.y * n_lit.z, n_lit.z * n_lit.z, n_lit.x * n_lit.z);
@@ -1992,7 +2007,7 @@ fn fragment(in: GxVsOut) -> @location(0) vec4<f32> {
wow_light.sh_c10_b.w,
);
let lit_doodad_plain = clamp(sun_lobe, vec3<f32>(0.0), vec3<f32>(1.0));
let lit_doodad = select(
var lit_doodad = select(
lit_doodad_plain,
clamp(
sun_ambient + (sun_lobe - sun_ambient) * shadow_term,
@@ -2001,6 +2016,11 @@ fn fragment(in: GxVsOut) -> @location(0) vec4<f32> {
),
world_shadow < 0.999,
);
// MONKEY (moon shadows): retained ADT doodads and exterior MODD props select this SH
// branch, not lit_nl_shadowed. Shadow the whole sky lobe before points/clamp here too.
if (world_moon < 1.0) {
lit_doodad = sun_lobe * world_moon;
}
// Sun disabled (light_sun.w) falls back to the FFP matte, like the entity path.
let lit = select(lit_nl_shadowed, lit_doodad, wow_light.light_sun.w > 0.5);
// FFP combine: the light sum saturates FIRST, the texture (× the baked constant
@@ -31,7 +31,10 @@ struct WowLight {
light_sun: vec4<f32>,
light_spec: vec4<f32>,
fog_color: vec4<f32>, // rgb row-7 fog (gamma); w = enable (>0.5)
fog_params: vec4<f32>, // x=start y=end z=linear-lighting A/B flag w=farclip wall
// MONKEY (moon shadows): `.z` is the SIGNED directional-shadow weight (+sun / -moon, see
// `global_light::pack_shadow_lane`) - unread here, but the old "linear-lighting A/B flag"
// note was stale twice over and a lane this shader mirrors should not misname it.
fog_params: vec4<f32>, // x=start y=end z=signed shadow weight w=farclip wall
sh_c10_r: vec4<f32>,
sh_c10_g: vec4<f32>,
sh_c10_b: vec4<f32>,