@@ -160,15 +160,21 @@ private static ShaderTraits GetTraits(
160160 // The CPU RecolorBrush observes a TPixel already written by earlier draws. A staged GPU
161161 // scene keeps those draws in f32 registers, so Recolor alone must reproduce the target's
162162 // physical storage conversion before comparing without reducing normal composition precision.
163+ // Binary16 quantization must round to nearest even like the CPU renderer's
164+ // float-to-half conversion; pack2x16float and the hardware store may truncate
165+ // toward zero, so the round trip routes through the explicit RTNE helper.
163166 string targetFormatRoundTripBody = textureFormat switch
164167 {
165168 WGPUTextureFormat . RGBA8Unorm or WGPUTextureFormat . BGRA8Unorm => "return unpack4x8unorm(pack4x8unorm(color));" ,
166169 WGPUTextureFormat . RGBA8Snorm => "return unpack4x8snorm(pack4x8snorm(color));" ,
167- WGPUTextureFormat . RGBA16Float => "return vec4<f32>(unpack2x16float(pack2x16float (color.rg)), unpack2x16float(pack2x16float( color.ba) ));"
170+ WGPUTextureFormat . RGBA16Float => "return vec4<f32>(quantize_f16_rtne(color.x), quantize_f16_rtne (color.y), quantize_f16_rtne( color.z), quantize_f16_rtne(color.w ));"
168171 } ;
169172
170- // Fine shading uses associated colors internally. Recolor explicitly crosses the target
171- // TPixel storage boundary, including associated-alpha rescaling when stored alpha quantizes.
173+ // Fine shading uses associated colors internally. Unassociated targets cross the storage
174+ // boundary with Numerics.UnPremultiply's exact semantics: zero alpha preserves RGB, and a
175+ // true division (not a reciprocal multiply) rounds identically to the CPU renderer.
176+ // Recolor additionally crosses the target TPixel storage boundary, including
177+ // associated-alpha rescaling when stored alpha quantizes.
172178 ( string Decode , string Encode , string RecolorNativeToInternal , string RecolorStoreTarget ) alphaBodies = alphaRepresentation switch
173179 {
174180 PixelAlphaRepresentation . Associated =>
@@ -180,14 +186,40 @@ private static ShaderTraits GetTraits(
180186 PixelAlphaRepresentation . Unassociated =>
181187 (
182188 "return premul_alpha(decode_numeric(color));" ,
183- "let a_inv = select( 0.0, 1.0 / color.a, color.a > 0.0 );\n return encode_numeric(vec4<f32>(color.rgb * a_inv , color.a));" ,
189+ "if color.a == 0.0 { \n return encode_numeric(color );\n } \n \n return encode_numeric(vec4<f32>(color.rgb / color.a , color.a));" ,
184190 "return premul_alpha(color);" ,
185- "let a_inv = select( 0.0, 1.0 / color.a, color.a > 0.0) ;\n let native = vec4<f32>(color.rgb * a_inv , color.a);\n return decode_numeric(round_trip_target_format(encode_numeric(native)));" )
191+ "if color.a == 0.0 { \n return decode_numeric(round_trip_target_format(encode_numeric(color))) ;\n } \n \n let native = vec4<f32>(color.rgb / color.a , color.a);\n return decode_numeric(round_trip_target_format(encode_numeric(native)));" )
186192 } ;
187193#pragma warning restore CS8509 , CS8524
188194
189195 string targetConversionFunctions =
190196 $$ """
197+ fn quantize_f16_rtne(value: f32) -> f32 {
198+ // Binary16 quantization with IEEE round-to-nearest-even. pack2x16float and the
199+ // hardware f32-to-f16 store conversion may truncate toward zero, so the two
200+ // bracketing half values are recovered by bit stepping (monotonic for one sign)
201+ // and the nearest is chosen, ties to the even mantissa, matching the CPU
202+ // renderer's float-to-half conversion exactly.
203+ let magnitude = abs(value);
204+ let packed = pack2x16float(vec2(magnitude, 0.0)) & 0xffffu;
205+ let snapped = unpack2x16float(packed).x;
206+ if snapped == magnitude {
207+ return select(snapped, -snapped, value < 0.0);
208+ }
209+
210+ let lower_bits = select(packed, packed - 1u, snapped > magnitude);
211+ let lower = unpack2x16float(lower_bits).x;
212+ let upper = unpack2x16float(lower_bits + 1u).x;
213+ let below = magnitude - lower;
214+ let above = upper - magnitude;
215+ var rounded = lower;
216+ if above < below || (above == below && (lower_bits & 1u) == 1u) {
217+ rounded = upper;
218+ }
219+
220+ return select(rounded, -rounded, value < 0.0);
221+ }
222+
191223 fn decode_numeric(color: vec4<f32>) -> vec4<f32> {
192224 {{ numericBodies . Decode }}
193225 }
@@ -220,16 +252,29 @@ fn decode_paint_color(color: vec4<f32>) -> vec4<f32> {
220252 return color;
221253 }
222254
223- fn pack_clip_color(color: vec4<f32>) -> vec2<u32> {
224- return vec2<u32>(pack2x16float(color.rg), pack2x16float(color.ba));
255+ fn pack_clip_color(color: vec4<f32>) -> vec4<u32> {
256+ // The clip stack preserves the backdrop across isolated layer pops, where
257+ // per-draw blend modes composite against it. The CPU renderer composes
258+ // against the exact target value, so the save must be bit-lossless:
259+ // binary16 packing here shifts results near storage rounding boundaries.
260+ return bitcast<vec4<u32>>(color);
225261 }
226262
227- fn unpack_clip_color(color: vec2 <u32>) -> vec4<f32> {
228- return vec4<f32>(unpack2x16float( color.x), unpack2x16float(color.y) );
263+ fn unpack_clip_color(color: vec4 <u32>) -> vec4<f32> {
264+ return bitcast< vec4<f32>>( color);
229265 }
230266 """ ;
231267
232- return new ShaderTraits ( compositeTraits . OutputFormat , targetConversionFunctions , "textureStore(output, vec2<i32>(coords), encode_target(rgba[i]));" ) ;
268+ // Every store quantizes in-shader first: hardware store conversions carry slack the
269+ // CPU renderer does not (f32-to-f16 may truncate toward zero, float-to-unorm allows
270+ // up to 0.6 ULP of error), while the spec-defined pack builtins and the explicit
271+ // binary16 helper round exactly like the CPU's pixel packing. Passing an exactly
272+ // representable value makes the hardware conversion lossless, so both backends
273+ // store identical pixels.
274+ return new ShaderTraits (
275+ compositeTraits . OutputFormat ,
276+ targetConversionFunctions ,
277+ "textureStore(output, vec2<i32>(coords), round_trip_target_format(encode_target(rgba[i])));" ) ;
233278 }
234279
235280 /// <summary>
0 commit comments