A from-scratch World of Warcraft 1.12.1 client in Rust + Bevy. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
270 lines
12 KiB
WebGPU Shading Language
270 lines
12 KiB
WebGPU Shading Language
// The glue screens' Bevy-UI node shader, on the UI **gamma composite lane** (decision 0254).
|
|
//
|
|
// VENDORED from `bevy_ui_render` 0.18.1 `src/ui.wgsl`. Everything below is that file verbatim —
|
|
// the rounded-box SDF, the border/AA math, the vertex stage — EXCEPT the fragment's colour
|
|
// computation, which is moved into the client's byte space (marked THE ONE CHANGE below). Bevy UI
|
|
// exposes no colour-space hook, and its pipeline takes one shader handle for both stages, so the
|
|
// whole file has to come along. Re-diff against upstream on every Bevy upgrade: a structural change
|
|
// (vertex attributes, bind groups) fails loudly at pipeline validation, but a fragment-side tweak
|
|
// would drift silently. `bevy = "0.18.1"` is pinned in Cargo.toml.
|
|
//
|
|
// Why: the reference draws its glue screens through a fixed-function device into an 8-bit
|
|
// backbuffer, so every UI multiply AND every UI blend is arithmetic on GAMMA BYTES. Bevy UI
|
|
// composites in linear, which over the login scene's bright sky washed the edit boxes from the
|
|
// reference's byte ~77 to ~138 — the director's "input and button are too faint", measured. This
|
|
// fragment hands the pipeline a RAW GAMMA value; the target is `Rgba8UnormSrgb`, so the hardware
|
|
// blend reads back exactly what was written and the `(SrcAlpha, OneMinusSrcAlpha)` blend runs on
|
|
// gamma values — the reference's byte arithmetic. `ui_gamma.wgsl` then owns the frame's ONE decode.
|
|
//
|
|
// The `#define_import_path` line of the original is deliberately dropped: two modules claiming
|
|
// `bevy_ui::ui_node` would collide.
|
|
|
|
#import bevy_render::view::View
|
|
|
|
const TEXTURED = 1u;
|
|
const RIGHT_VERTEX = 2u;
|
|
const BOTTOM_VERTEX = 4u;
|
|
// must align with BORDER_* shader_flags from bevy_ui/render/mod.rs
|
|
const BORDER_LEFT: u32 = 256u;
|
|
const BORDER_TOP: u32 = 512u;
|
|
const BORDER_RIGHT: u32 = 1024u;
|
|
const BORDER_BOTTOM: u32 = 2048u;
|
|
const BORDER_ANY: u32 = BORDER_LEFT + BORDER_TOP + BORDER_RIGHT + BORDER_BOTTOM;
|
|
|
|
fn enabled(flags: u32, mask: u32) -> bool {
|
|
return (flags & mask) != 0u;
|
|
}
|
|
|
|
// Linear → sRGB (the exact IEC 61966-2-1 curve the hardware's sRGB store uses, so this inverts the
|
|
// sampler's decode bit-for-bit in f32). Alpha carries no gamma and never passes through here.
|
|
// Identical to `ui_quad.wgsl`'s — the player-UI lane's twin of this conversion.
|
|
fn linear_to_srgb(c: vec3<f32>) -> vec3<f32> {
|
|
let higher = 1.055 * pow(max(c, vec3<f32>(0.0)), vec3<f32>(1.0 / 2.4)) - 0.055;
|
|
let lower = c * 12.92;
|
|
return select(higher, lower, c <= vec3<f32>(0.0031308));
|
|
}
|
|
|
|
@group(0) @binding(0) var<uniform> view: View;
|
|
|
|
struct VertexOutput {
|
|
@location(0) uv: vec2<f32>,
|
|
@location(1) color: vec4<f32>,
|
|
|
|
@location(2) @interpolate(flat) size: vec2<f32>,
|
|
@location(3) @interpolate(flat) flags: u32,
|
|
@location(4) @interpolate(flat) radius: vec4<f32>,
|
|
@location(5) @interpolate(flat) border: vec4<f32>,
|
|
|
|
// Position relative to the center of the rectangle.
|
|
@location(6) point: vec2<f32>,
|
|
@builtin(position) position: vec4<f32>,
|
|
};
|
|
|
|
@vertex
|
|
fn vertex(
|
|
@location(0) vertex_position: vec3<f32>,
|
|
@location(1) vertex_uv: vec2<f32>,
|
|
@location(2) vertex_color: vec4<f32>,
|
|
@location(3) flags: u32,
|
|
|
|
// x: top left, y: top right, z: bottom right, w: bottom left.
|
|
@location(4) radius: vec4<f32>,
|
|
|
|
// x: left, y: top, z: right, w: bottom.
|
|
@location(5) border: vec4<f32>,
|
|
@location(6) size: vec2<f32>,
|
|
@location(7) point: vec2<f32>,
|
|
) -> VertexOutput {
|
|
var out: VertexOutput;
|
|
out.uv = vertex_uv;
|
|
out.position = view.clip_from_world * vec4(vertex_position, 1.0);
|
|
out.color = vertex_color;
|
|
out.flags = flags;
|
|
out.radius = radius;
|
|
out.size = size;
|
|
out.border = border;
|
|
out.point = point;
|
|
|
|
return out;
|
|
}
|
|
|
|
@group(1) @binding(0) var sprite_texture: texture_2d<f32>;
|
|
@group(1) @binding(1) var sprite_sampler: sampler;
|
|
|
|
// The returned value is the shortest distance from the given point to the boundary of the rounded
|
|
// box.
|
|
//
|
|
// Negative values indicate that the point is inside the rounded box, positive values that the point
|
|
// is outside, and zero is exactly on the boundary.
|
|
//
|
|
// Arguments:
|
|
// - `point` -> The function will return the distance from this point to the closest point on
|
|
// the boundary.
|
|
// - `size` -> The maximum width and height of the box.
|
|
// - `corner_radii` -> The radius of each rounded corner. Ordered counter clockwise starting
|
|
// top left:
|
|
// x: top left, y: top right, z: bottom right, w: bottom left.
|
|
fn sd_rounded_box(point: vec2<f32>, size: vec2<f32>, corner_radii: vec4<f32>) -> f32 {
|
|
// If 0.0 < y then select bottom left (w) and bottom right corner radius (z).
|
|
// Else select top left (x) and top right corner radius (y).
|
|
let rs = select(corner_radii.xy, corner_radii.wz, 0.0 < point.y);
|
|
// w and z are swapped above so that both pairs are in left to right order, otherwise this second
|
|
// select statement would return the incorrect value for the bottom pair.
|
|
let radius = select(rs.x, rs.y, 0.0 < point.x);
|
|
// Vector from the corner closest to the point, to the point.
|
|
let corner_to_point = abs(point) - 0.5 * size;
|
|
// Vector from the center of the radius circle to the point.
|
|
let q = corner_to_point + radius;
|
|
// Length from center of the radius circle to the point, zeros a component if the point is not
|
|
// within the quadrant of the radius circle that is part of the curved corner.
|
|
let l = length(max(q, vec2(0.0)));
|
|
let m = min(max(q.x, q.y), 0.0);
|
|
return l + m - radius;
|
|
}
|
|
|
|
fn sd_inset_rounded_box(point: vec2<f32>, size: vec2<f32>, radius: vec4<f32>, inset: vec4<f32>) -> f32 {
|
|
let inner_size = size - inset.xy - inset.zw;
|
|
let inner_center = inset.xy + 0.5 * inner_size - 0.5 * size;
|
|
let inner_point = point - inner_center;
|
|
|
|
var r = radius;
|
|
|
|
// Top left corner.
|
|
r.x = r.x - max(inset.x, inset.y);
|
|
|
|
// Top right corner.
|
|
r.y = r.y - max(inset.z, inset.y);
|
|
|
|
// Bottom right corner.
|
|
r.z = r.z - max(inset.z, inset.w);
|
|
|
|
// Bottom left corner.
|
|
r.w = r.w - max(inset.x, inset.w);
|
|
|
|
let half_size = inner_size * 0.5;
|
|
let min_size = min(half_size.x, half_size.y);
|
|
|
|
r = min(max(r, vec4(0.0)), vec4<f32>(min_size));
|
|
|
|
return sd_rounded_box(inner_point, inner_size, r);
|
|
}
|
|
|
|
fn nearest_border_active(point_vs_mid: vec2<f32>, size: vec2<f32>, width: vec4<f32>, flags: u32) -> bool {
|
|
if (flags & BORDER_ANY) == BORDER_ANY {
|
|
return true;
|
|
}
|
|
|
|
// get point vs top left
|
|
let point = clamp(point_vs_mid + size * 0.49999, vec2(0.0), size);
|
|
|
|
let left = point.x / width.x;
|
|
let top = point.y / width.y;
|
|
let right = (size.x - point.x) / width.z;
|
|
let bottom = (size.y - point.y) / width.w;
|
|
|
|
let min_dist = min(min(left, top), min(right, bottom));
|
|
|
|
return (enabled(flags, BORDER_LEFT) && min_dist == left) ||
|
|
(enabled(flags, BORDER_TOP) && min_dist == top) ||
|
|
(enabled(flags, BORDER_RIGHT) && min_dist == right) ||
|
|
(enabled(flags, BORDER_BOTTOM) && min_dist == bottom);
|
|
}
|
|
|
|
// get alpha for antialiasing for sdf
|
|
fn antialias(distance: f32) -> f32 {
|
|
// Using the fwidth(distance) was causing artifacts, so just use the distance.
|
|
return saturate(0.5 - distance);
|
|
}
|
|
|
|
fn draw_uinode_border(
|
|
color: vec4<f32>,
|
|
point: vec2<f32>,
|
|
size: vec2<f32>,
|
|
radius: vec4<f32>,
|
|
border: vec4<f32>,
|
|
flags: u32,
|
|
) -> vec4<f32> {
|
|
// Signed distances. The magnitude is the distance of the point from the edge of the shape.
|
|
// * Negative values indicate that the point is inside the shape.
|
|
// * Zero values indicate the point is on the edge of the shape.
|
|
// * Positive values indicate the point is outside the shape.
|
|
|
|
// Signed distance from the exterior boundary.
|
|
let external_distance = sd_rounded_box(point, size, radius);
|
|
|
|
// Signed distance from the border's internal edge (the signed distance is negative if the point
|
|
// is inside the rect but not on the border).
|
|
// If the border size is set to zero, this is the same as the external distance.
|
|
let internal_distance = sd_inset_rounded_box(point, size, radius, border);
|
|
|
|
// Signed distance from the border (the intersection of the rect with its border).
|
|
// Points inside the border have negative signed distance. Any point outside the border, whether
|
|
// outside the outside edge, or inside the inner edge have positive signed distance.
|
|
let border_distance = max(external_distance, -internal_distance);
|
|
|
|
// check if this node should apply color for the nearest border
|
|
let nearest_border = select(0.0, 1.0, nearest_border_active(point, size, border, flags));
|
|
|
|
#ifdef ANTI_ALIAS
|
|
// At external edges with no border, `border_distance` is equal to zero.
|
|
// This select statement ensures we only perform anti-aliasing where a non-zero width border
|
|
// is present, otherwise an outline about the external boundary would be drawn even without
|
|
// a border.
|
|
let t = select(1.0 - step(0.0, border_distance), antialias(border_distance), external_distance < internal_distance);
|
|
#else
|
|
let t = 1.0 - step(0.0, border_distance);
|
|
#endif
|
|
|
|
// Blend mode ALPHA_BLENDING is used for UI elements, so we don't premultiply alpha here.
|
|
return vec4(color.rgb, saturate(color.a * t * nearest_border));
|
|
}
|
|
|
|
fn draw_uinode_background(
|
|
color: vec4<f32>,
|
|
point: vec2<f32>,
|
|
size: vec2<f32>,
|
|
radius: vec4<f32>,
|
|
border: vec4<f32>,
|
|
) -> vec4<f32> {
|
|
// When drawing the background only draw the internal area and not the border.
|
|
let internal_distance = sd_inset_rounded_box(point, size, radius, border);
|
|
|
|
#ifdef ANTI_ALIAS
|
|
let t = antialias(internal_distance);
|
|
#else
|
|
let t = 1.0 - step(0.0, internal_distance);
|
|
#endif
|
|
|
|
return vec4(color.rgb, saturate(color.a * t));
|
|
}
|
|
|
|
@fragment
|
|
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
|
|
let texture_color = textureSample(sprite_texture, sprite_sampler, in.uv);
|
|
|
|
// ── THE ONE CHANGE from the vendored original ────────────────────────────────────────────────
|
|
// Upstream computes `select(in.color, in.color * texture_color, TEXTURED)` — a LINEAR product
|
|
// handed to a linear blend. Both factors go back to the client's byte space FIRST, and the
|
|
// multiply happens there: `in.color` is a linearised authored sRGB colour (FrameXML `<Color>`,
|
|
// the glue palette) and the sampler already decoded the sRGB art, so encoding each factor
|
|
// recovers exactly the two authored bytes the reference's FFP multiplies. Encoding each factor
|
|
// separately — rather than the product — matters: sRGB's linear toe makes `encode(a*b)` and
|
|
// `encode(a)*encode(b)` diverge in the darks, which is precisely where the edit-box fills live
|
|
// (byte 13 vs byte 7 for the `DEFAULT_TOOLTIP_COLOR` fill).
|
|
// Alpha is coverage, carries no gamma, and multiplies unchanged.
|
|
let textured = enabled(in.flags, TEXTURED);
|
|
let tint = linear_to_srgb(in.color.rgb);
|
|
let texel = linear_to_srgb(texture_color.rgb);
|
|
let color = vec4<f32>(
|
|
select(tint, tint * texel, textured),
|
|
select(in.color.a, in.color.a * texture_color.a, textured),
|
|
);
|
|
// ── end of the change; the rest is upstream verbatim ─────────────────────────────────────────
|
|
|
|
if enabled(in.flags, BORDER_ANY) {
|
|
return draw_uinode_border(color, in.point, in.size, in.radius, in.border, in.flags);
|
|
} else {
|
|
return draw_uinode_background(color, in.point, in.size, in.radius, in.border);
|
|
}
|
|
}
|