From aa2ee515aa0af6223fbb9962c6d939fd1e515646 Mon Sep 17 00:00:00 2001 From: grom Date: Mon, 2 Nov 2026 10:00:44 -0700 Subject: [PATCH] ui: MOD multiplies, and a gradient is not an alternative to a fill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cinematic's colour tint painted the whole screen opaque white for as long as its CineMode ran: `srgb(255,255,255)` across the world with the UI still drawn on top of it, 16.8 s of it in grom's 2026-11-02 recording, starting the instant he picked a profile. Three faults on the one path from the addon's call to the pixel, each of them enough on its own. `SetBlendMode("MOD")` parsed, answered `GetBlendMode` and then drew as straight alpha. One pipeline served BLEND and ADD — the shader emitting `(rgb·a, a)` or `(rgb·a, 0)` into a single `(One, OneMinusSrcAlpha)` state — because no scalar source alpha can stand in for a per-channel destination factor, and the mode had no caller worth a second pipeline. It had one: nine of the addon's ten tint presets are MOD (`Tint.lua:21`), and a MOD colour is built to be multiplied — `1 − (1 − c)·strength` at **alpha 1**, white at strength 0 — so drawn with alpha instead it is an opaque near-white quad over UIParent. MOD now keys its own pipeline, `(Dst, Zero)` with the destination's own alpha kept, off the same `0x70c190` table the world's Mod batches already read; the shader returns the multiplier un-premultiplied, since neither alpha appears in that formula, and runs no longer merge across the flag. The extract read `fill.or_else(|| gradient.map(midpoint))`, so a region that set both drew the fill and dropped the gradient. They are not alternatives: `` writes the four vertex colours (`+0xb8`, `0x77304d`), which `loader/regions.rs` already reads as "unlike `` it tints the art". The tint is exactly that shape — `SetColorTexture(1, 1, 1, 1)` once, re-gradiented a few times a second — and so is its vignette, whose four edges were drawing as opaque white bars. And `SetGradient` took only 1.12's six loose numbers. This client answers `CreateColor`, so an addon that feature-detects takes retail's colour-object form and never tries the numbers; the two objects coerced to six zeros through `as_f32`, the call **succeeded**, and the `pcall` that was the feature test (`Tint.lua:546`) reported it understood. Both spellings now read a colour object when the first stop is a table. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn --- crates/benilla-app/src/shaders/ui_quad.wgsl | 6 + crates/benilla-app/src/ui_pass.rs | 63 ++++++++- .../benilla-app/src/ui_script/extract/mod.rs | 2 + .../benilla-app/src/ui_script/extract/text.rs | 2 + crates/benilla-ui/src/script/extract.rs | 19 ++- crates/benilla-ui/src/script/region/paint.rs | 54 ++++++-- crates/benilla-ui/src/script/tests/regions.rs | 127 ++++++++++++++++++ crates/benilla-ui/src/script/types.rs | 4 + 8 files changed, 258 insertions(+), 19 deletions(-) diff --git a/crates/benilla-app/src/shaders/ui_quad.wgsl b/crates/benilla-app/src/shaders/ui_quad.wgsl index 15055dde..2b674d5e 100644 --- a/crates/benilla-app/src/shaders/ui_quad.wgsl +++ b/crates/benilla-app/src/shaders/ui_quad.wgsl @@ -78,6 +78,9 @@ fn vertex(vertex: Vertex) -> VertexOutput { // Set only for a booth bake (portrait, paper doll, dressing room), already premultiplied because // its additive particles add light with no coverage; every other texture is straight alpha. @group(2) @binding(8) var premultiplied: u32; +// EGxBlend 4 (`MOD`): the pipeline's blend state is `(DST_COLOR, ZERO)`, so the fragment is the +// multiplier itself. Neither alpha appears in that formula, so neither appears below. +@group(2) @binding(12) var modulate: u32; // Alpha-test reference, <= 0 disables; only the WMO-interior minimap tiles, which the reference // draws under EGxBlend 1: no blend, `glAlphaFunc(GL_GEQUAL, 224/255)` (`0x85ad20[1]`) on // `texel.a × frameAlpha`. The screen mask stays out: the reference cuts it at the offscreen blit. @@ -151,6 +154,9 @@ fn fragment(in: VertexOutput) -> @location(0) vec4 { // Premultiply in gamma, not by the hardware `SrcAlpha`. A booth bake takes `k` alone: `t.a` // again would zero its additive light over empty space. let weight = select(a, k, premultiplied != 0u); + if modulate != 0u { + return vec4(rgb, 1.0); + } if additive != 0u { return vec4(rgb * weight, 0.0); } diff --git a/crates/benilla-app/src/ui_pass.rs b/crates/benilla-app/src/ui_pass.rs index f4fd9e9d..9e40050a 100644 --- a/crates/benilla-app/src/ui_pass.rs +++ b/crates/benilla-app/src/ui_pass.rs @@ -77,6 +77,10 @@ pub(crate) struct UiQuad { pub color: [f32; 4], /// WoW `ADD` blend (EGxBlend 3, `glBlendFunc(GL_SRC_ALPHA, GL_ONE)`) instead of straight alpha. pub additive: bool, + /// WoW `MOD` blend (EGxBlend 4, `glBlendFunc(GL_DST_COLOR, GL_ZERO)`): the quad multiplies + /// what is under it and its alpha plays no part. Its own pipeline, not a shader path: no + /// scalar source alpha can stand in for a per-channel destination factor. + pub modulate: bool, /// Mask to the inscribed circle: the live unit portrait. pub circular: bool, /// Draw the texel's luminance (`Texture:SetDesaturated(1)`), folded in gamma bytes before the @@ -130,6 +134,7 @@ impl Default for UiQuad { uv: UvRect::FULL, color: [1.0, 1.0, 1.0, 1.0], additive: false, + modulate: false, circular: false, desaturated: false, premultiplied: false, @@ -205,9 +210,15 @@ struct UiWhiteTexture(Handle); /// premultiplied-alpha pipeline (`ui_quad.wgsl`). It holds no colour, so it is its identity key /// alone and a steady frame re-prepares none. #[derive(Asset, TypePath, AsBindGroup, Clone)] +#[bind_group_data(UiQuadKey)] pub(crate) struct UiQuadMaterial { #[uniform(0)] additive: u32, + /// The `MOD` blend. A **specialization key** as well as a uniform: it picks the pipeline's + /// blend state in [`Self::specialize`], because a per-channel destination factor is not + /// something the shader can reach from inside the one premultiplied state. + #[uniform(12)] + modulate: u32, #[texture(1)] #[sampler(2)] texture: Option>, @@ -233,6 +244,21 @@ pub(crate) struct UiQuadMaterial { uv_clamp: Vec4, } +/// The one axis the UI pipeline specializes on: `MOD` needs its own blend state, and a blend state +/// is a pipeline. Everything else about a quad is a uniform the one pipeline reads. +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +pub(crate) struct UiQuadKey { + modulate: bool, +} + +impl From<&UiQuadMaterial> for UiQuadKey { + fn from(m: &UiQuadMaterial) -> Self { + Self { + modulate: m.modulate != 0, + } + } +} + /// A run's one colour for the batch entity's [`MeshTag`], unpacked by `ui_quad.wgsl`'s vertex /// stage, so a colour pulse is a component write, never a mesh or material write. One byte per /// channel, `×255 + 0.5`, as the reference packs a vertex colour (`CImVector`; `SetVertexColor` @@ -255,6 +281,7 @@ impl UiQuadMaterial { pub(crate) fn interior_tile(texture: Handle, alpha_ref: f32) -> Self { Self { additive: 0, + modulate: 0, texture: Some(texture), circular: 0, desaturate: 0, @@ -294,7 +321,7 @@ impl Material2d for UiQuadMaterial { fn specialize( descriptor: &mut bevy::render::render_resource::RenderPipelineDescriptor, _layout: &bevy::mesh::MeshVertexBufferLayoutRef, - _key: Material2dKey, + key: Material2dKey, ) -> Result<(), bevy::render::render_resource::SpecializedMeshPipelineError> { if let Some(fragment) = descriptor.fragment.as_mut() { if let Some(target) = fragment.targets.first_mut().and_then(|t| t.as_mut()) { @@ -306,9 +333,29 @@ impl Material2d for UiQuadMaterial { dst_factor: BlendFactor::OneMinusSrcAlpha, operation: BlendOperation::Add, }; - target.blend = Some(BlendState { - color: premultiplied, - alpha: premultiplied, + target.blend = Some(if key.bind_group_data.modulate { + // EGxBlend 4, `GL_DST_COLOR, GL_ZERO` (`0x70c190`, the same table the model + // materials read): the quad multiplies what is already in the target, in + // gamma, which is the reference's byte math. No source alpha appears in it, + // which is why the shader returns the factor un-premultiplied. The + // destination's own alpha is kept, as it is for the world's Mod batches. + BlendState { + color: BlendComponent { + src_factor: BlendFactor::Dst, + dst_factor: BlendFactor::Zero, + operation: BlendOperation::Add, + }, + alpha: BlendComponent { + src_factor: BlendFactor::Zero, + dst_factor: BlendFactor::One, + operation: BlendOperation::Add, + }, + } + } else { + BlendState { + color: premultiplied, + alpha: premultiplied, + } }); } } @@ -462,6 +509,7 @@ fn clip_quad(rect: Rect, uv: UvRect, clip: Rect) -> Option<(Rect, UvRect)> { struct Run { texture: Handle, additive: bool, + modulate: bool, circular: bool, desaturated: bool, premultiplied: bool, @@ -481,6 +529,7 @@ impl Run { Self { texture, additive: q.additive, + modulate: q.modulate, circular: q.circular, desaturated: q.desaturated, premultiplied: q.premultiplied, @@ -670,6 +719,7 @@ type MatKey = ( bool, bool, bool, + bool, u32, Option>, [u32; 4], @@ -774,7 +824,7 @@ fn rebuild_ui_mesh( // Only the two resize sites emit this event, so a steady frame does no work here. if !reshaped.is_empty() { for (key, material) in &pools.materials { - if reshaped.contains(&key.0) || key.7.is_some_and(|mask| reshaped.contains(&mask)) { + if reshaped.contains(&key.0) || key.8.is_some_and(|mask| reshaped.contains(&mask)) { // The touch is the point; the value is not wanted. let _ = stores.1.get_mut(material.id()); } @@ -927,6 +977,7 @@ fn rebuild_ui_mesh( let same_run = runs.last().is_some_and(|r| { (r.texture == texture || one_texture_probe()) && r.additive == q.additive + && r.modulate == q.modulate && r.circular == q.circular && r.desaturated == q.desaturated && r.premultiplied == q.premultiplied @@ -998,6 +1049,7 @@ fn rebuild_ui_mesh( let key: MatKey = ( run.texture.id(), run.additive, + run.modulate, run.circular, run.desaturated, run.premultiplied, @@ -1038,6 +1090,7 @@ fn rebuild_ui_mesh( .or_insert_with(|| { materials.add(UiQuadMaterial { additive: u32::from(run.additive), + modulate: u32::from(run.modulate), texture: Some(run.texture.clone()), circular: u32::from(run.circular), desaturate: u32::from(run.desaturated), diff --git a/crates/benilla-app/src/ui_script/extract/mod.rs b/crates/benilla-app/src/ui_script/extract/mod.rs index 1adc4313..ba168a5b 100644 --- a/crates/benilla-app/src/ui_script/extract/mod.rs +++ b/crates/benilla-app/src/ui_script/extract/mod.rs @@ -1322,6 +1322,7 @@ fn convert_entry( path, color, additive, + modulate, tex_coords, circular, portrait_unit, @@ -1490,6 +1491,7 @@ fn convert_entry( uv_clamp, color, additive, + modulate, clip, // `SetRotation` is counter-clockwise-positive, `UiQuad::rotation` clockwise. rotation: -rotation, diff --git a/crates/benilla-app/src/ui_script/extract/text.rs b/crates/benilla-app/src/ui_script/extract/text.rs index 5689ba22..05545833 100644 --- a/crates/benilla-app/src/ui_script/extract/text.rs +++ b/crates/benilla-app/src/ui_script/extract/text.rs @@ -140,6 +140,7 @@ pub(super) fn emit( uv: UvRect::FULL, color: [hc[0], hc[1], hc[2], hc[3] * host.alpha], additive: false, + modulate: false, circular: false, desaturated: false, premultiplied: false, @@ -321,6 +322,7 @@ pub(super) fn emit( uv: UvRect::FULL, color: [1.0, 1.0, 1.0, host.alpha], additive: false, + modulate: false, circular: false, desaturated: false, premultiplied: false, diff --git a/crates/benilla-ui/src/script/extract.rs b/crates/benilla-ui/src/script/extract.rs index 74ecdd63..8a5f191c 100644 --- a/crates/benilla-ui/src/script/extract.rs +++ b/crates/benilla-ui/src/script/extract.rs @@ -427,7 +427,8 @@ impl UiScript { a.1.max(b.1) + half, a.0.max(b.0) + half, )); - let fill = data.fill.or_else(|| data.gradient.map(|g| g.midpoint())); + // A fill and a gradient compose, as at the texture arm below. + let fill = texture_color(data.fill, data.gradient.map(|g| g.midpoint())); let has_texture = data.texture.is_some() || fill.is_some(); QuadContent::Line { path: data.texture, @@ -443,7 +444,16 @@ impl UiScript { // The draw gate is the texture slot, never the colour (`0x7706e0`: empty // `+0xcc` emits nothing); a vertex colour alone is a tint. A gradient fills // the texture slot, so it draws, at its midpoint since a quad has one tint. - let fill = data.fill.or_else(|| data.gradient.map(|g| g.midpoint())); + // + // A fill and a gradient are not alternatives: the gradient writes the four + // vertex colours (`+0xb8`, `0x77304d`), which `loader/regions.rs` already + // reads as "unlike `` it tints the art", so a region carrying both + // draws their product. Measured: Cinematic's screen tint is a fullscreen + // `SetColorTexture(1, 1, 1, 1)` re-gradiented a few times a second + // (`Tint.lua:567`, `:641`). Letting the fill win painted an opaque white + // quad over UIParent — the whole screen white under the UI — for as long + // as its CineMode ran. + let fill = texture_color(data.fill, data.gradient.map(|g| g.midpoint())); let has_path = data.texture.is_some(); let has_texture = has_path || fill.is_some(); QuadContent::Texture { @@ -451,9 +461,10 @@ impl UiScript { color: has_texture .then(|| texture_color(fill, data.vertex_color)) .flatten(), - // The renderer acts on ADD only: the other four `alphaMode` values - // answer `GetBlendMode` but draw as straight alpha. + // ADD and MOD each have their own blend state; the remaining three + // `alphaMode` values answer `GetBlendMode` but draw as straight alpha. additive: data.blend == crate::script::BlendMode::Add, + modulate: data.blend == crate::script::BlendMode::Mod, tex_coords: data.tex_coords, circular: data.circular, portrait_unit: data.portrait_unit, diff --git a/crates/benilla-ui/src/script/region/paint.rs b/crates/benilla-ui/src/script/region/paint.rs index 4cc2d2eb..4b808b00 100644 --- a/crates/benilla-ui/src/script/region/paint.rs +++ b/crates/benilla-ui/src/script/region/paint.rs @@ -73,7 +73,8 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { // SetGradientAlpha(orientation, r1, g1, b1, a1, r2, g2, b2, a2) and SetGradient without the // alphas: a two-stop gradient the client generates into the texture slot (`+0xcc`); here it is // stored whole and drawn as its midpoint. Any orientation but "VERTICAL", in any case, is - // horizontal. + // horizontal. Either name also takes retail's two colour objects in place of the loose + // numbers — see the arm below for why that one is not optional here. for (name, with_alpha) in [("SetGradientAlpha", true), ("SetGradient", false)] { m.set( name, @@ -88,16 +89,49 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { // A missing or non-string orientation is horizontal. _ => String::new(), }; - let n = if with_alpha { 8 } else { 6 }; - let mut c = [0.0f32; 8]; - for slot in c.iter_mut().take(n) { - *slot = it.next().as_ref().map(|v| as_f32(lua, v)).unwrap_or(0.0); - } - let (start, end) = if with_alpha { - ([c[0], c[1], c[2], c[3]], [c[4], c[5], c[6], c[7]]) + let stops: Vec = it.collect(); + let (start, end) = if let Some(Value::Table(_)) = stops.first() { + // Retail's `SetGradient(orientation, fromColor, toColor)`, the two + // `ColorMixin` form. 1.12 has only the loose numbers — but this client answers + // `CreateColor` (`compat/classic_api/color_mixin.rs`), and an addon that finds + // that global takes the object path and never tries the numbers. Cinematic's + // tint helper does exactly that, inside a `pcall` whose success *is* its + // feature test (`Tint.lua:546`): reading the object here is what makes that + // test honest. Without it each stop coerced to 0 through `as_f32`, the call + // still succeeded, and its fullscreen tint drew flat black. + // + // The object's alpha is used even under the `SetGradient` name, which is what + // retail does: the alpha-less pair is the *numeric* form's shape, not this + // one's. A missing second colour repeats the first, so a one-colour call is a + // flat tint rather than a fade to black. + let read = |v: Option<&Value>| -> mlua::Result> { + let Some(Value::Table(t)) = v else { + return Ok(None); + }; + Ok(Some([ + as_f32(lua, &t.get::("r")?), + as_f32(lua, &t.get::("g")?), + as_f32(lua, &t.get::("b")?), + match t.get::("a")? { + Value::Nil => 1.0, + a => as_f32(lua, &a), + }, + ])) + }; + let from = read(stops.first())?.unwrap_or([0.0, 0.0, 0.0, 1.0]); + (from, read(stops.get(1))?.unwrap_or(from)) } else { - // SetGradient has no alpha stops: both ends are opaque. - ([c[0], c[1], c[2], 1.0], [c[3], c[4], c[5], 1.0]) + let n = if with_alpha { 8 } else { 6 }; + let mut c = [0.0f32; 8]; + for (i, slot) in c.iter_mut().take(n).enumerate() { + *slot = stops.get(i).map(|v| as_f32(lua, v)).unwrap_or(0.0); + } + if with_alpha { + ([c[0], c[1], c[2], c[3]], [c[4], c[5], c[6], c[7]]) + } else { + // SetGradient has no alpha stops: both ends are opaque. + ([c[0], c[1], c[2], 1.0], [c[3], c[4], c[5], 1.0]) + } }; let rh = region_handle_of(lua, &this)?; let mut model = lua.app_data_mut::().expect("model"); diff --git a/crates/benilla-ui/src/script/tests/regions.rs b/crates/benilla-ui/src/script/tests/regions.rs index 449b4a2d..64411765 100644 --- a/crates/benilla-ui/src/script/tests/regions.rs +++ b/crates/benilla-ui/src/script/tests/regions.rs @@ -823,6 +823,133 @@ fn a_gradient_is_stored_whole_and_painted_as_its_midpoint() { ); } +/// `SetGradient(orientation, fromColor, toColor)`: retail's two-colour-object form, which this +/// client has to answer because it answers `CreateColor`. An addon that finds that global takes +/// the object path and never tries the loose numbers — Cinematic's tint helper does exactly that, +/// inside a `pcall` whose success *is* its feature test (`Tint.lua:546`). With only the numeric +/// form the stops each coerced to 0 and the tint drew flat black over the whole screen. +#[test] +fn a_gradient_takes_the_modern_colour_objects_this_client_also_answers() { + let mut s = script(); + s.run( + r#" + f = CreateFrame("Frame", "ObjGradProbe") + f:SetWidth(100) f:SetHeight(20) + f:SetPoint("TOPLEFT", 0, 0) + t = f:CreateTexture(nil, "BACKGROUND") + t:SetAllPoints(f) + t:SetTexture(1, 1, 1, 1) + local from, to = CreateColor(0.2, 0.3, 0.4, 0.5), CreateColor(0.2, 0.3, 0.4, 0.1) + -- The addon's own call, pcall and all: its success decides whether it ever tries the + -- number form, so an accepted call must also have been an understood one. + assert(pcall(t.SetGradient, t, "VERTICAL", from, to), "the object form must be accepted") + "#, + ) + .unwrap(); + + s.resolve(); + let c = s + .extract() + .iter() + .find_map(|q| match &q.content { + crate::script::QuadContent::Texture { color: Some(c), .. } => Some(*c), + _ => None, + }) + .expect("the tinted quad paints"); + let near = |a: f32, b: f32| (a - b).abs() < 1e-6; + assert!( + near(c[0], 0.2) && near(c[1], 0.3) && near(c[2], 0.4) && near(c[3], 0.3), + "the objects' own channels, at the midpoint of their alphas: {c:?}" + ); +} + +/// A colour fill and a gradient compose rather than shadow each other: the gradient writes the +/// vertex colours (`+0xb8`), so the white an addon lays down first is the texel the gradient +/// tints, not a colour that outranks it. +/// +/// Cinematic's screen tint is this exact shape — a fullscreen `SetColorTexture(1, 1, 1, 1)` +/// re-gradiented a few times a second (`Tint.lua:567`, `:641`). With the fill winning it painted +/// an opaque white quad over UIParent: a white screen with the UI still drawn on top of it, for +/// as long as its CineMode ran. +#[test] +fn a_colour_fill_under_a_gradient_draws_their_product() { + let mut s = script(); + s.run( + r#" + f = CreateFrame("Frame", "TintProbe") + f:SetWidth(100) f:SetHeight(20) + f:SetPoint("TOPLEFT", 0, 0) + t = f:CreateTexture(nil, "BACKGROUND") + t:SetAllPoints(f) + -- Cinematic's own pair, in its own order. + t:SetTexture(1, 1, 1, 1) + t:SetGradientAlpha("VERTICAL", 0.2, 0.3, 0.4, 0.5, 0.2, 0.3, 0.4, 0.1) + "#, + ) + .unwrap(); + + s.resolve(); + let c = s + .extract() + .iter() + .find_map(|q| match &q.content { + crate::script::QuadContent::Texture { color: Some(c), .. } => Some(*c), + _ => None, + }) + .expect("the tint quad paints"); + let near = |a: f32, b: f32| (a - b).abs() < 1e-6; + assert!( + near(c[0], 0.2) && near(c[1], 0.3) && near(c[2], 0.4) && near(c[3], 0.3), + "white times the gradient is the gradient, at the midpoint of its stops: {c:?}" + ); +} + +/// `SetBlendMode("MOD")` reaches the quad as its own flag, not as straight alpha. EGxBlend 4 is +/// `GL_DST_COLOR, GL_ZERO`, a per-channel multiply of what is already drawn, and nothing about a +/// straight-alpha quad stands in for it: a fullscreen MOD wash drawn with alpha instead covers +/// the screen in its own colour instead of shading what is under it. Cinematic's colour tint is +/// one of those (`Tint.lua:21`: nine of its ten presets are MOD). +#[test] +fn the_mod_blend_reaches_the_quad_as_its_own_flag() { + let mut s = script(); + s.run( + r#" + f = CreateFrame("Frame", "ModProbe") + f:SetWidth(100) f:SetHeight(20) + f:SetPoint("TOPLEFT", 0, 0) + mod_tex = f:CreateTexture(nil, "BACKGROUND") + mod_tex:SetAllPoints(f) + mod_tex:SetTexture(1, 0.9, 0.8, 1) + mod_tex:SetBlendMode("MOD") + add_tex = f:CreateTexture(nil, "ARTWORK") + add_tex:SetAllPoints(f) + add_tex:SetTexture(1, 1, 1, 1) + add_tex:SetBlendMode("ADD") + "#, + ) + .unwrap(); + + s.resolve(); + let flags: Vec<(bool, bool)> = s + .extract() + .iter() + .filter_map(|q| match &q.content { + crate::script::QuadContent::Texture { + additive, modulate, .. + } => Some((*additive, *modulate)), + _ => None, + }) + .collect(); + assert!( + flags.contains(&(false, true)), + "the MOD texture must carry modulate and not additive: {flags:?}" + ); + assert!( + flags.contains(&(true, false)), + "and ADD must still be additive alone: {flags:?}" + ); +} + /// Texture's map (`0x87c128`, 22 entries) and FontString's (`0xcf5400`, 32) each answer their own /// verbs, then fall back to the Region map and stop there. #[test] diff --git a/crates/benilla-ui/src/script/types.rs b/crates/benilla-ui/src/script/types.rs index feacdd64..44e4dc85 100644 --- a/crates/benilla-ui/src/script/types.rs +++ b/crates/benilla-ui/src/script/types.rs @@ -154,6 +154,10 @@ pub enum QuadContent { color: Option<[f32; 4]>, /// The `ADD` blend instead of straight alpha. additive: bool, + /// The `MOD` blend (`GL_DST_COLOR, GL_ZERO`): the quad multiplies what is already there + /// instead of covering it, and its alpha plays no part. Carried apart from `additive` + /// because the two are different pipelines, not two shader paths through one. + modulate: bool, /// `None` is the full texture. tex_coords: Option, /// A portrait, masked to the inscribed circle; false with a `portrait_unit` is the square