zaniah 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0e4185eb60f475f07fb3deee4bf07f0e7e16240771ee9199f9783d3fe13130cb
4
- data.tar.gz: 05cafb090e57d8552cda0826d8db0ff1fa980107eab05ab9841824d6a18187c6
3
+ metadata.gz: cc06ae1720a8d05abbc0da78849a355f7c36565c62b3907f941c80dd066594c5
4
+ data.tar.gz: fd00f6d9bd08c097a5ffa35ce079a83f949c07874d6ff7c76cdc802f7ddd8129
5
5
  SHA512:
6
- metadata.gz: e5da9a69b48d186dcb04cf71cf48922d9e72fd019ff54919451f028cc211262fd8c49ecf85eb6dbbfd8b48213ad324ab98aefc9079473ace7d8a289401079ae4
7
- data.tar.gz: f2b7ada32c16d932143db6cf68a9c2073e085ef0cda762aa7ed2f07ae7fdee5b9d5b1f5c3a5289b5fc54930fab9dbef1c0720e8035656c8ac700a2904012f431
6
+ metadata.gz: 769bfa73b348d773f2139eb34039a7250f6878986375bdd25c361c3922b16d61ab68a3d14c59f8ef7f3cfd68e04a43d868286b624d1996d2fc2afdb92b23f861
7
+ data.tar.gz: a9642fa580bd9595e15c48903e698b95dfc5a6f876ca0d71438e488899522e2855351cef18a124e005dae5636e53b500aa46e90e101575c12796fc4e9ae80992
data/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.0 — 2026-09-24
4
+
5
+ - Add semantic vector recording for UI scenes, including text, images, and raster fallbacks for unsupported drawing.
6
+ - Render shadows analytically across backends; their edge appearance changes from earlier releases.
7
+ - Add shared multi-stop and conic gradient ramps across Software, OpenGL, Metal, and Vulkan rendering.
8
+ - Support SVG gradients, dashed strokes, and alpha and luminance masks.
9
+
3
10
  ## 0.8.0 — 2026-09-24
4
11
 
5
12
  - Add native macOS and Windows menus, declarative context menus, and platform-aware shortcut labels.
@@ -26,10 +26,10 @@ fields for source UVs. Triangle vertices reuse rect and radii fields.
26
26
  | 32–37 | affine transform (`a,b,c,d,tx,ty`) | affine transform |
27
27
  | 38–39 | reserved shadow spread / inset or border flags | unused |
28
28
 
29
- Opacity is multiplied into both fill and border alpha before packing. Linear
30
- and radial gradients use exactly two stops; a future multi-stop implementation
31
- may use a 1D texture without another stride change. Shadows are expanded into
32
- ordinary quad instances, so the reserved shadow fields remain available.
29
+ Opacity is multiplied into both fill and border alpha before packing. The
30
+ original two-stop and expanded-quad behavior has since been extended without
31
+ changing this layout: see [ADR 017](017-analytic-shadows.md) for the shadow
32
+ fields and [ADR 018](018-gradient-ramps.md) for multistop ramps and conic fills.
33
33
 
34
34
  Metal, OpenGL, and Vulkan consume the layout directly, Software consumes the
35
35
  equivalent 40-float quad layout, and cached text sprite batches use the same
@@ -0,0 +1,33 @@
1
+ # ADR 016: Record semantic drawing alongside the GPU scene
2
+
3
+ - Status: Proposed
4
+ - Date: 2026-09-24
5
+
6
+ ## Context
7
+
8
+ The GPU scene contains packed quads and atlas sprites. Paths have already been
9
+ rasterized and text has lost its font, glyph IDs, and source clusters by the
10
+ time a renderer consumes those commands. Exporters need this information to
11
+ produce searchable text and scalable artwork. Reconstructing it from packed
12
+ GPU instances is impossible; maintaining a second application-specific layout
13
+ for export makes screen and export drift.
14
+
15
+ Two viable boundaries were considered: make the GPU scene itself semantic, or
16
+ keep its fast packed representation and optionally observe the drawing calls.
17
+
18
+ ## Decision
19
+
20
+ Keep the GPU scene unchanged and attach an optional vector sink to `Scene`.
21
+ Emit semantic commands before paths and text are flattened. When a source has
22
+ no supported vector form, preserve its pixels as a raster command instead.
23
+ The sink is absent by default, so normal frames do not allocate vector data.
24
+ Page assembly and document formats remain outside Zaniah.
25
+
26
+ ## Consequences
27
+
28
+ Exporters can reuse the same element tree and retain font and outline data
29
+ without adding a dependency on a PDF library. The optional path incurs a
30
+ nil check when unused and stores additional frame data when enabled. Raster
31
+ fallbacks keep output complete but cannot scale as cleanly as true vectors.
32
+ This choice should be revisited if packed GPU commands become a lossless
33
+ semantic representation or a common display list can serve both consumers.
@@ -0,0 +1,31 @@
1
+ # ADR 017: Draw each shadow as one analytic instance
2
+
3
+ - Status: Accepted
4
+ - Date: 2026-09-24
5
+
6
+ ## Context
7
+
8
+ The previous shadow approximation layered eight rounded quads. It produced
9
+ visible bands and made a scene with 100 shadows submit 800 instances.
10
+
11
+ ## Decision
12
+
13
+ Keep the 40-float instance layout from ADR 005. Primitive kind `4` in field 31
14
+ is a shadow; field 30 is blur sigma, field 38 is spread, and field 39 is the
15
+ inset flag. Bounds 0–3 are expanded by `spread + 3 * blur + 1` for outer
16
+ shadows. Inset shadows retain the original bounds. Corner radii occupy 12–15.
17
+
18
+ Software and all three GPU fragment shaders evaluate the signed distance to
19
+ the original rounded rectangle. A Gaussian edge is approximated by
20
+ `0.5 - 0.5 * erf((distance - spread) / (sqrt(2) * blur))`, using the same bounded
21
+ exponential approximation of `erf` in every backend. Zero blur uses direct
22
+ coverage. Inset shadows multiply interior coverage by the inverted transition.
23
+ The existing clipping, transforms, and alpha blending remain unchanged.
24
+
25
+ ## Consequences
26
+
27
+ Shadow appearance intentionally changes; the golden images and changelog must
28
+ record this. `bench/shadow.rb` compares 100 shadows against the old eight-quad
29
+ approximation and requires the analytic Software path to be faster. GPU native
30
+ capture tests remain platform-dependent; `script/compile_shaders` regenerates
31
+ the bundled Vulkan SPIR-V from the checked-in GLSL.
@@ -0,0 +1,30 @@
1
+ # ADR 018: Use bounded ramps for multistop gradients
2
+
3
+ - Status: Accepted
4
+ - Date: 2026-09-24
5
+
6
+ ## Context
7
+
8
+ The shared instance format carries only two inline stop colors. Extending its
9
+ stride would affect every renderer and text/sprite packing.
10
+
11
+ ## Decision
12
+
13
+ Two-stop linear and radial gradients retain their inline color path. Two-stop
14
+ conic gradients use kind `3` and the same interpolation path. Three or more
15
+ stops use kinds `4` (linear), `5` (radial), or `6` (conic), and one row of a
16
+ 256-by-256 RGBA8 atlas baked from the ordered stops. Field 8 stores the row
17
+ index; all renderers sample that row at the calculated gradient position. A
18
+ `Scene` caches rows by immutable `Gradient` value in an LRU capped at 256
19
+ entries. Rows used in the current frame cannot be evicted; if a frame exceeds
20
+ 256 distinct ramps, it uses reusable overflow atlases for the remainder of
21
+ that frame. Normal quads continue to use packed-byte fast paths; only ramp
22
+ quads need texture-aware batching.
23
+
24
+ ## Consequences
25
+
26
+ No instance layout migration is required. Sampling precision is limited to
27
+ 256 positions. Adjacent ramps on the shared atlas batch together, while
28
+ overflows can add batches. SVG gradient fills use the same ordered-stop
29
+ interpolation semantics and fall back to a raster snapshot in vector recording
30
+ because a `Vector::Path` currently stores solid fills only.
data/docs/vector.md ADDED
@@ -0,0 +1,60 @@
1
+ # Vector recording
2
+
3
+ Zaniah records the drawing it already performs without taking ownership of PDF
4
+ pages, paper sizes, or export dialogs. Require `zaniah/vector` and render a normal
5
+ element tree offscreen:
6
+
7
+ ```ruby
8
+ document = Zaniah::Vector.record(width: 960, height: 540,
9
+ theme: Zaniah::Theme.light) { slide }
10
+ document.commands.each { |command| export_command(command) }
11
+ ```
12
+
13
+ To inspect a displayed frame, attach a recorder before rendering. `Scene#clear`
14
+ resets its commands each frame; save `recorder.document` before the next frame.
15
+
16
+ ```ruby
17
+ recorder = Zaniah::Vector::Recorder.new
18
+ window.scene.vector_sink = recorder
19
+ window.tick
20
+ document = recorder.document
21
+ window.scene.vector_sink = nil
22
+ ```
23
+
24
+ `Document#width` and `#height` are logical pixels. A `Recorder` attached to a
25
+ window receives that window's content size; a recorder attached to a bare
26
+ `Scene` may instead be initialized with `width:` and `height:`. Coordinates use
27
+ a top-left origin and downward y axis; colors are sRGB. Every command carries
28
+ its transform, clip, opacity, layer, and sequence. `Document#commands` is sorted
29
+ by layer and then sequence, matching `Scene#each_command`.
30
+
31
+ | Command | Export information |
32
+ |---|---|
33
+ | `Quad` | Bounds, fill color or gradient, corner radii, border widths/color/style |
34
+ | `Shadow` | Bounds, corner radii, color, blur, spread, inset |
35
+ | `Path` | Alhena outline, fill, stroke, stroke width, fill rule and stroke cap/join |
36
+ | `GlyphRun` | Alhena font, size, `[glyph_id, x, baseline_y]` triples, original text and UTF-8 byte clusters |
37
+ | `Image` | Decoded `Zaniah::Image`, bounds and source rectangle, plus a frozen pixel snapshot |
38
+ | `Underline` | Position, thickness and wave flag |
39
+ | `Raster` | Frozen source texture bytes, pixel dimensions/format, tint and source rectangle |
40
+
41
+ Ordinary sprites and packed sprite batches become `Raster`; bitmap-color text
42
+ uses that fallback for the whole line. Simple SVG fills and strokes become
43
+ `Path` commands. SVG clipping, gradients, dash arrays, masks, group opacity,
44
+ and references use a `Raster`
45
+ fallback, so they are not silently omitted. The `Raster` bytes are a snapshot
46
+ at recording time, even if the source texture changes later. The converter must
47
+ crop to `source`, apply `color` and `opacity`, then respect `transform` and
48
+ `clip`. It may rasterize a `Path` or `GlyphRun` when its output format lacks
49
+ an equivalent vector primitive.
50
+
51
+ The headless offscreen API works on every OS and does not require a GPU.
52
+ Attaching a recorder to macOS, Windows, X11, or Wayland uses the same `Scene`
53
+ path. The TUI backend captures its terminal cells as a single `Raster` fallback
54
+ at an 8-by-20 cell grid, using a bundled font approximation; terminal font
55
+ appearance is outside Zaniah's control. Use the offscreen API when scalable
56
+ text is required.
57
+
58
+ See [ADR 016](adr/016-semantic-vector-recording.md) for why this is separate
59
+ from the GPU command stream. [Vector and list](vector_and_list.md) documents
60
+ the SVG input subset.
@@ -13,15 +13,24 @@ icon = Zaniah::SVG.parse(
13
13
  ```
14
14
 
15
15
  The renderer supports SVG paths, basic shapes, groups, transforms, view boxes,
16
- inherited fill and stroke, `currentColor`, opacity, local `defs`/`use`, and
17
- user-space clip paths. It is intended for static icons, not arbitrary web SVG.
16
+ inherited fill and stroke, `currentColor`, opacity, local `defs`/`use`,
17
+ user-space clip paths, linear/radial gradients, stroke dash arrays and offsets,
18
+ and local alpha/luminance masks. It is intended for static icons, not arbitrary
19
+ web SVG.
18
20
 
19
- Scripts, external references, CSS stylesheets, gradients, filters, masks, images,
20
- text, markers, dash arrays, nested viewports, and object-bounding-box clips are
21
+ Scripts, external references, CSS stylesheets, filters, images,
22
+ text, markers, nested viewports, and object-bounding-box clips are
21
23
  unsupported and raise `ArgumentError`. Input is bounded to 2 MiB, 10,000 XML
22
24
  nodes, 64 levels, and 100,000 path operations; each output texture is limited to
23
25
  one megapixel.
24
26
 
27
+ SVG gradients support ordered stops, local references, `objectBoundingBox` and
28
+ `userSpaceOnUse` coordinates, and the default pad spread. Other spread methods
29
+ and external references remain unsupported. Masks rasterize within the same
30
+ one-megapixel output limit. Gradient, dashed, and masked shapes are represented
31
+ as a `Vector::Raster` fallback when recording; simple solid outlines remain
32
+ `Vector::Path` commands.
33
+
25
34
  ## Variable-height lists
26
35
 
27
36
  ```ruby
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../lib/zaniah"
4
+ abort "This check needs macOS and a graphical login" unless RUBY_PLATFORM.include?("darwin")
5
+
6
+ window = Zaniah::Platform.open_window(backend: :mac, gpu: :metal, width: 96, height: 72,
7
+ title: "Metal effect check")
8
+ begin
9
+ scene = Zaniah::Scene.new
10
+ scene.quad(4, 4, 30, 24, color: Zaniah::Gradient.linear(angle: 0,
11
+ stops: [[0, "#f00"], [0.5, "#0f0"], [1, "#00f"]]))
12
+ scene.quad(38, 4, 30, 24, color: Zaniah::Gradient.conic(stops: [[0, "#f00"], [1, "#00f"]]))
13
+ scene.shadow(28, 40, 32, 18, color: "#000", blur: 3, spread: 1, radius: 4)
14
+ expected = Zaniah::GPU::Software.new(96, 72).render(scene, clear: "#fff")
15
+ window.device.render(scene, clear: "#fff")
16
+ actual = window.device.pixels
17
+ scale = window.scale_factor
18
+ pixel_width = (96 * scale).round
19
+ samples = [[6, 15], [18, 15], [31, 15], [60, 16], [22, 49], [28, 49], [44, 49], [75, 49]]
20
+ samples.each do |x, y|
21
+ index = (y * 96 + x) * 4
22
+ native_index = (((y + 0.5) * scale).floor * pixel_width + ((x + 0.5) * scale).floor) * 4
23
+ left, right = expected.byteslice(index, 4).bytes, actual.byteslice(native_index, 4).bytes
24
+ raise "effect mismatch at #{x},#{y}: #{right.inspect} != #{left.inspect}" unless
25
+ left.zip(right).all? { |a, b| (a - b).abs <= 8 }
26
+ end
27
+ puts "Metal analytic shadow and gradient check: #{samples.length} samples passed"
28
+ ensure
29
+ window.close
30
+ end
@@ -17,6 +17,7 @@ module Zaniah
17
17
  case kind
18
18
  when :quad
19
19
  q, i = scene.quads, offset
20
+ texture = scene.quad_texture(offset)
20
21
  data.push(q[i], q[i+1], q[i+2], q[i+3], q[i+4], q[i+5], q[i+6], q[i+7],
21
22
  q[i+8], q[i+9], q[i+10], q[i+11], q[i+12], q[i+13], q[i+14], q[i+15],
22
23
  q[i+16], q[i+17], q[i+18], q[i+19], q[i+20], q[i+21], q[i+22], q[i+23],
@@ -64,6 +65,7 @@ module Zaniah
64
65
  expected = 0
65
66
  scene.each_command do |kind, offset, clip|
66
67
  return unless kind == :quad && offset == expected
68
+ return if scene.quad_texture(offset)
67
69
  if batches.empty? || batches.last[0][2] != clip
68
70
  batches << [[:quad, nil, clip], expected / STRIDE, 1]
69
71
  else
@@ -20,7 +20,7 @@ module Zaniah
20
20
  using namespace metal;
21
21
  struct Vertex { float4 position [[position]]; float2 local; float2 size;
22
22
  float4 color; float4 secondary; float4 radii; float4 border; float4 widths;
23
- float4 gradient; float4 center_kind; float2 uv; float dash; };
23
+ float4 gradient; float4 center_kind; float2 uv; float dash; float spread; };
24
24
  vertex Vertex zaniah_vertex(uint v [[vertex_id]], uint instance [[instance_id]],
25
25
  const device float *data [[buffer(0)]], constant float2 &viewport [[buffer(1)]]) {
26
26
  const device float *p = data + instance * 40;
@@ -37,17 +37,26 @@ module Zaniah
37
37
  out.radii = float4(p[12],p[13],p[14],p[15]); out.border = float4(p[16],p[17],p[18],p[19]);
38
38
  out.widths = float4(p[20],p[21],p[22],p[23]); out.gradient = float4(p[24],p[25],p[26],p[27]);
39
39
  out.center_kind = float4(p[28],p[29],p[30],p[31]);
40
- out.uv = float2(p[20],p[21]) + corner * float2(p[22],p[23]); out.dash = p[39]; return out;
40
+ out.uv = float2(p[20],p[21]) + corner * float2(p[22],p[23]); out.dash = p[39]; out.spread = p[38]; return out;
41
+ }
42
+ float approx_erf(float value) {
43
+ float square = value * value;
44
+ return sign(value) * sqrt(1.0f - exp(-square * (1.27323954474f + 0.147f * square) / (1.0f + 0.147f * square)));
41
45
  }
42
46
  fragment float4 zaniah_fragment(Vertex in [[stage_in]], texture2d<float> atlas [[texture(0)]]) {
43
47
  float4 color = in.color;
44
48
  if (in.center_kind.w == 0) {
45
49
  if (in.gradient.x > 0) {
46
50
  float2 normalized = in.local / in.size;
47
- float raw = in.gradient.x == 1
51
+ float raw = in.gradient.x == 1 || in.gradient.x == 4
48
52
  ? dot(normalized - 0.5f, float2(cos(in.gradient.w * 0.01745329252f), sin(in.gradient.w * 0.01745329252f))) + 0.5f
49
- : length(normalized - in.center_kind.xy) / in.center_kind.z;
50
- color = mix(in.color, in.secondary, clamp((raw - in.gradient.y) / max(in.gradient.z - in.gradient.y, 0.000001f), 0.0f, 1.0f));
53
+ : in.gradient.x == 2 || in.gradient.x == 5 ? length(normalized - in.center_kind.xy) / in.center_kind.z
54
+ : fract((atan2(normalized.y - in.center_kind.y, normalized.x - in.center_kind.x) - in.gradient.w * 0.01745329252f) / 6.28318530718f + 1.0f);
55
+ float amount = clamp((raw - in.gradient.y) / max(in.gradient.z - in.gradient.y, 0.000001f), 0.0f, 1.0f);
56
+ constexpr sampler gradient_sampler(coord::normalized, address::clamp_to_edge, filter::nearest);
57
+ float2 ramp_uv = float2((floor(amount * 255.0f + 0.5f) + 0.5f) / 256.0f, (in.secondary.x + 0.5f) / 256.0f);
58
+ color = in.gradient.x >= 4 ? atlas.sample(gradient_sampler, ramp_uv) * float4(1.0f,1.0f,1.0f,in.color.a)
59
+ : mix(in.color, in.secondary, amount);
51
60
  }
52
61
  float radius = in.local.y < in.size.y/2 ? (in.local.x < in.size.x/2 ? in.radii.x : in.radii.y) : (in.local.x < in.size.x/2 ? in.radii.w : in.radii.z);
53
62
  radius = clamp(radius, 0.0f, min(in.size.x,in.size.y)/2);
@@ -60,6 +69,19 @@ module Zaniah
60
69
  float coordinate = edge == edge_distance.x || edge == edge_distance.z ? in.local.x : in.local.y;
61
70
  if (border_width > 0 && distance >= -border_width && !(in.dash == 1 && fmod(coordinate,6.0f) >= 3.0f)) color = in.border;
62
71
  color.a *= coverage;
72
+ } else if (in.center_kind.w == 4) {
73
+ float margin = in.dash == 1 ? 0.0f : in.spread + in.center_kind.z * 3.0f + 1.0f;
74
+ float2 original_size = in.size - 2.0f * margin;
75
+ float2 p = in.local - margin;
76
+ float radius = p.y < original_size.y/2.0f ? (p.x < original_size.x/2.0f ? in.radii.x : in.radii.y) : (p.x < original_size.x/2.0f ? in.radii.w : in.radii.z);
77
+ radius = clamp(radius, 0.0f, min(original_size.x,original_size.y)/2.0f);
78
+ float2 q = abs(p - original_size/2.0f) - original_size/2.0f + radius;
79
+ float distance = length(max(q,0.0f)) + min(max(q.x,q.y),0.0f) - radius;
80
+ float sigma = in.center_kind.z;
81
+ float coverage = in.dash == 1
82
+ ? clamp(0.5f - distance, 0.0f, 1.0f) * (sigma == 0.0f ? clamp(distance + in.spread + 0.5f, 0.0f, 1.0f) : 0.5f + 0.5f * approx_erf((distance + in.spread) / (sigma * 1.41421356237f)))
83
+ : sigma == 0.0f ? clamp(0.5f - distance + in.spread, 0.0f, 1.0f) : 0.5f - 0.5f * approx_erf((distance - in.spread) / (sigma * 1.41421356237f));
84
+ color.a *= coverage;
63
85
  } else if (in.center_kind.w == 1 || in.center_kind.w == 2) {
64
86
  constexpr sampler texture_sampler(coord::normalized, address::clamp_to_edge, filter::nearest);
65
87
  float4 sample = atlas.sample(texture_sampler,in.uv);
@@ -23,7 +23,7 @@ module Zaniah
23
23
  uniform vec2 viewport;
24
24
  out vec2 local_position; out vec2 size; out vec4 color; out vec4 radii;
25
25
  out vec4 secondary; out vec4 border; out vec4 widths; out vec4 gradient;
26
- out vec4 center_kind; out vec2 uv; out float dash;
26
+ out vec4 center_kind; out vec2 uv; out float dash; out float spread;
27
27
  void main() {
28
28
  vec2 vertices[4] = vec2[4](vec2(0,0),vec2(1,0),vec2(0,1),vec2(1,1));
29
29
  vec2 corner = vertices[gl_VertexID];
@@ -35,25 +35,35 @@ module Zaniah
35
35
  local_position = corner * rect.zw; size = rect.zw; color = tint;
36
36
  secondary = tint2; radii = corners; border = border_color; widths = edges_source;
37
37
  gradient = gradient_data; center_kind = gradient_center_kind;
38
- uv = edges_source.xy + corner * edges_source.zw; dash = translation_flags.w;
38
+ uv = edges_source.xy + corner * edges_source.zw; dash = translation_flags.w; spread = translation_flags.z;
39
39
  }
40
40
  GLSL
41
41
  FRAGMENT = <<~GLSL.freeze
42
42
  #version 330 core
43
43
  in vec2 local_position; in vec2 size; in vec4 color; in vec4 radii;
44
44
  in vec4 secondary; in vec4 border; in vec4 widths; in vec4 gradient;
45
- in vec4 center_kind; in vec2 uv; in float dash;
45
+ in vec4 center_kind; in vec2 uv; in float dash; in float spread;
46
46
  uniform sampler2D atlas;
47
47
  out vec4 output_color;
48
+ float approx_erf(float value) {
49
+ float square = value * value;
50
+ return sign(value) * sqrt(1 - exp(-square * (1.27323954474 + 0.147 * square) / (1 + 0.147 * square)));
51
+ }
48
52
  void main() {
49
53
  vec4 result = color;
50
54
  if (center_kind.w == 0) {
51
55
  if (gradient.x > 0) {
52
56
  vec2 normalized = local_position / size;
53
- float raw = gradient.x == 1
57
+ float raw = gradient.x == 1 || gradient.x == 4
54
58
  ? dot(normalized - 0.5, vec2(cos(radians(gradient.w)), sin(radians(gradient.w)))) + 0.5
55
- : length(normalized - center_kind.xy) / center_kind.z;
56
- result = mix(color, secondary, clamp((raw - gradient.y) / max(gradient.z - gradient.y, 0.000001), 0, 1));
59
+ : gradient.x == 2 || gradient.x == 5 ? length(normalized - center_kind.xy) / center_kind.z
60
+ : fract((atan(normalized.y - center_kind.y, normalized.x - center_kind.x) - radians(gradient.w)) / 6.28318530718 + 1.0);
61
+ float amount = clamp((raw - gradient.y) / max(gradient.z - gradient.y, 0.000001), 0, 1);
62
+ if (gradient.x >= 4) {
63
+ vec2 ramp_uv = vec2((floor(amount * 255.0 + 0.5) + 0.5) / 256.0, (secondary.x + 0.5) / 256.0);
64
+ vec4 sample_color = texture(atlas, ramp_uv);
65
+ result = vec4(sample_color.rgb, sample_color.a * color.a);
66
+ } else result = mix(color, secondary, amount);
57
67
  }
58
68
  float radius = local_position.y < size.y/2 ? (local_position.x < size.x/2 ? radii.x : radii.y) : (local_position.x < size.x/2 ? radii.w : radii.z);
59
69
  radius = clamp(radius, 0, min(size.x,size.y)/2);
@@ -66,6 +76,19 @@ module Zaniah
66
76
  float coordinate = edge == edge_distance.x || edge == edge_distance.z ? local_position.x : local_position.y;
67
77
  if (border_width > 0 && distance >= -border_width && !(dash == 1 && mod(coordinate,6) >= 3)) result = border;
68
78
  result.a *= coverage;
79
+ } else if (center_kind.w == 4) {
80
+ float margin = dash == 1 ? 0 : spread + center_kind.z * 3 + 1;
81
+ vec2 original_size = size - 2 * margin;
82
+ vec2 p = local_position - margin;
83
+ float radius = p.y < original_size.y/2 ? (p.x < original_size.x/2 ? radii.x : radii.y) : (p.x < original_size.x/2 ? radii.w : radii.z);
84
+ radius = clamp(radius, 0, min(original_size.x,original_size.y)/2);
85
+ vec2 q = abs(p - original_size/2) - original_size/2 + radius;
86
+ float distance = length(max(q,0)) + min(max(q.x,q.y),0) - radius;
87
+ float sigma = center_kind.z;
88
+ float coverage = dash == 1
89
+ ? clamp(0.5 - distance, 0, 1) * (sigma == 0 ? clamp(distance + spread + 0.5, 0, 1) : 0.5 + 0.5 * approx_erf((distance + spread) / (sigma * 1.41421356237)))
90
+ : sigma == 0 ? clamp(0.5 - distance + spread, 0, 1) : 0.5 - 0.5 * approx_erf((distance - spread) / (sigma * 1.41421356237));
91
+ result.a *= coverage;
69
92
  } else if (center_kind.w == 1 || center_kind.w == 2) {
70
93
  vec4 sample_color = texture(atlas,uv);
71
94
  if (center_kind.w == 1) result.a *= sample_color.r; else result *= sample_color;
@@ -27,7 +27,7 @@ module Zaniah
27
27
  scene.each_command do |kind, offset, clip|
28
28
  bounds = clip ? @viewport.intersect(clip) : @viewport
29
29
  case kind
30
- when :quad then draw_quad(scene.quads, offset, bounds)
30
+ when :quad then draw_quad(scene, offset, bounds)
31
31
  when :sprite then draw_sprite(scene, offset, bounds)
32
32
  when :sprite_batch
33
33
  first, count = scene.expand_sprite_batch(offset)
@@ -43,8 +43,9 @@ module Zaniah
43
43
 
44
44
  private
45
45
 
46
- def draw_quad(data, offset, clip)
47
- values = data.slice(offset, Scene::QUAD_STRIDE)
46
+ def draw_quad(scene, offset, clip)
47
+ values = scene.quads.slice(offset, Scene::QUAD_STRIDE)
48
+ return draw_shadow(values, clip) if values[31] == 4
48
49
  x, y, width, height = values.first(4)
49
50
  color, secondary = values[4, 4], values[8, 4]
50
51
  radii, border_color, borders = values[12, 4], values[16, 4], values[20, 4]
@@ -59,12 +60,13 @@ module Zaniah
59
60
  end
60
61
  gradient = gradient_parameters(gradient)
61
62
  if matrix == Transform.identity && borders.all?(&:zero?) && radii.all?(&:zero?) &&
62
- color[3] == 1 && secondary[3] == 1 && gradient[0].positive? &&
63
+ color[3] == 1 && secondary[3] == 1 && (1..2).cover?(gradient[0]) &&
63
64
  [x, y, width, height].all? { |value| value == value.to_i }
64
65
  fill_cached_gradient(bounds, x.to_i, y.to_i, width.to_i, height.to_i, color, secondary, gradient)
65
66
  return
66
67
  end
67
68
  inverse = matrix.inverse
69
+ ramp_data = scene.quad_texture(offset)&.data
68
70
  top, bottom = [bounds.y.floor, 0].max, [bounds.bottom.ceil, @height].min
69
71
  left, right = [bounds.x.floor, 0].max, [bounds.right.ceil, @width].min
70
72
  pixel_y = top
@@ -80,7 +82,14 @@ module Zaniah
80
82
  distance = Math.sqrt([quad_x, 0].max**2 + [quad_y, 0].max**2) + [[quad_x, quad_y].max, 0].min - radius
81
83
  coverage = (0.5 - distance).clamp(0, 1)
82
84
  if coverage.positive?
83
- fill = gradient[0].zero? ? color : mix(color, secondary, gradient_offset(gradient, local_x, local_y, width, height))
85
+ fill = if gradient[0].zero?
86
+ color
87
+ elsif ramp_data
88
+ ramp_index = (secondary[0].to_i * 256 + (gradient_offset(gradient, local_x, local_y, width, height) * 255).round) * 4
89
+ 4.times.map { |index| ramp_data.getbyte(ramp_index + index) / 255.0 }.tap { |sample| sample[3] *= color[3] }
90
+ else
91
+ mix(color, secondary, gradient_offset(gradient, local_x, local_y, width, height))
92
+ end
84
93
  edge = [local_y, width - local_x, height - local_y, local_x].each_with_index.min_by(&:first).last
85
94
  border = borders[edge]
86
95
  dashed = values[39] == 1 && ((edge.odd? ? local_y : local_x) % 6) >= 3
@@ -93,6 +102,45 @@ module Zaniah
93
102
  end
94
103
  end
95
104
 
105
+ def draw_shadow(values, clip)
106
+ x, y, width, height = values.first(4)
107
+ color, radii = values[4, 4], values[12, 4]
108
+ blur, spread, inset = values[30], values[38], values[39] == 1
109
+ return if inset && blur.zero? && spread.zero?
110
+ matrix = Transform.new(*values[32, 6])
111
+ inverse = matrix.inverse
112
+ bounds = transformed_bounds(x, y, width, height, matrix).intersect(clip)
113
+ margin = inset ? 0 : spread + blur * 3 + 1
114
+ origin_x, origin_y = x + margin, y + margin
115
+ content_width, content_height = width - margin * 2, height - margin * 2
116
+ top, bottom = [bounds.y.floor, 0].max, [bounds.bottom.ceil, @height].min
117
+ left, right = [bounds.x.floor, 0].max, [bounds.right.ceil, @width].min
118
+ (top...bottom).each do |pixel_y|
119
+ (left...right).each do |pixel_x|
120
+ point = inverse.apply(Point.new(pixel_x + 0.5, pixel_y + 0.5))
121
+ local_x, local_y = point.x - origin_x, point.y - origin_y
122
+ radius = local_y < content_height / 2.0 ? (local_x < content_width / 2.0 ? radii[0] : radii[1]) : (local_x < content_width / 2.0 ? radii[3] : radii[2])
123
+ radius = radius.clamp(0, [content_width, content_height].min / 2.0)
124
+ qx = (local_x - content_width / 2.0).abs - content_width / 2.0 + radius
125
+ qy = (local_y - content_height / 2.0).abs - content_height / 2.0 + radius
126
+ distance = Math.hypot([qx, 0].max, [qy, 0].max) + [[qx, qy].max, 0].min - radius
127
+ coverage = if inset
128
+ inside = (0.5 - distance).clamp(0, 1)
129
+ inside * (blur.zero? ? (distance + spread + 0.5).clamp(0, 1) : (0.5 + 0.5 * erf_approx((distance + spread) / (blur * Math.sqrt(2)))))
130
+ else
131
+ blur.zero? ? (0.5 - distance + spread).clamp(0, 1) : (0.5 - 0.5 * erf_approx((distance - spread) / (blur * Math.sqrt(2))))
132
+ end
133
+ blend(pixel_x, pixel_y, *color.first(3), color[3] * coverage) if coverage.positive?
134
+ end
135
+ end
136
+ end
137
+
138
+ def erf_approx(value)
139
+ sign = value.negative? ? -1 : 1
140
+ square = value * value
141
+ sign * Math.sqrt(1 - Math.exp(-square * (4.0 / Math::PI + 0.147 * square) / (1 + 0.147 * square)))
142
+ end
143
+
96
144
  def fill_rect(bounds, red, green, blue)
97
145
  left, right = [bounds.x.to_i, 0].max, [bounds.right.to_i, @width].min
98
146
  top, bottom = [bounds.y.to_i, 0].max, [bounds.bottom.to_i, @height].min
@@ -216,7 +264,11 @@ module Zaniah
216
264
  def gradient_offset(gradient, x, y, width, height)
217
265
  kind, first, scale, cosine, sine, center_x, center_y, radius = gradient
218
266
  nx, ny = x / width, y / height
219
- raw = kind == 1 ? (nx - 0.5) * cosine + (ny - 0.5) * sine + 0.5 : Math.hypot(nx - center_x, ny - center_y) / radius
267
+ raw = case kind
268
+ when 1, 4 then (nx - 0.5) * cosine + (ny - 0.5) * sine + 0.5
269
+ when 2, 5 then Math.hypot(nx - center_x, ny - center_y) / radius
270
+ else ((Math.atan2(ny - center_y, nx - center_x) - Math.atan2(sine, cosine)) / (2 * Math::PI)) % 1
271
+ end
220
272
  ((raw - first) * scale).clamp(0, 1)
221
273
  end
222
274
 
@@ -10,20 +10,29 @@ layout(location = 6) in vec4 widths;
10
10
  layout(location = 7) in vec4 gradient;
11
11
  layout(location = 8) in vec4 center_kind;
12
12
  layout(location = 9) in float dash;
13
+ layout(location = 10) in float spread;
13
14
 
14
15
  layout(set = 0, binding = 0) uniform sampler2D atlas;
15
16
  layout(location = 0) out vec4 fragment_color;
16
17
 
18
+ float approx_erf(float value) {
19
+ float square = value * value;
20
+ return sign(value) * sqrt(1.0 - exp(-square * (1.27323954474 + 0.147 * square) / (1.0 + 0.147 * square)));
21
+ }
22
+
17
23
  void main() {
18
24
  vec4 fill = color;
19
25
  if (center_kind.w == 0.0) {
20
26
  if (gradient.x > 0.0) {
21
27
  vec2 normalized = local_position / primitive_size;
22
- float raw = gradient.x == 1.0
28
+ float raw = gradient.x == 1.0 || gradient.x == 4.0
23
29
  ? dot(normalized - 0.5, vec2(cos(radians(gradient.w)), sin(radians(gradient.w)))) + 0.5
24
- : length(normalized - center_kind.xy) / center_kind.z;
25
- fill = mix(color, secondary,
26
- clamp((raw - gradient.y) / max(gradient.z - gradient.y, 0.000001), 0.0, 1.0));
30
+ : gradient.x == 2.0 || gradient.x == 5.0 ? length(normalized - center_kind.xy) / center_kind.z
31
+ : fract((atan(normalized.y - center_kind.y, normalized.x - center_kind.x) - radians(gradient.w)) / 6.28318530718 + 1.0);
32
+ float amount = clamp((raw - gradient.y) / max(gradient.z - gradient.y, 0.000001), 0.0, 1.0);
33
+ vec2 ramp_uv = vec2((floor(amount * 255.0 + 0.5) + 0.5) / 256.0, (secondary.x + 0.5) / 256.0);
34
+ fill = gradient.x >= 4.0 ? texture(atlas, ramp_uv) * vec4(1.0,1.0,1.0,color.a)
35
+ : mix(color, secondary, amount);
27
36
  }
28
37
  float radius = local_position.y < primitive_size.y / 2.0
29
38
  ? (local_position.x < primitive_size.x / 2.0 ? radii.x : radii.y)
@@ -41,6 +50,19 @@ void main() {
41
50
  if (border_width > 0.0 && distance >= -border_width &&
42
51
  !(dash == 1.0 && mod(coordinate, 6.0) >= 3.0)) fill = border;
43
52
  fill.a *= coverage;
53
+ } else if (center_kind.w == 4.0) {
54
+ float margin = dash == 1.0 ? 0.0 : spread + center_kind.z * 3.0 + 1.0;
55
+ vec2 original_size = primitive_size - 2.0 * margin;
56
+ vec2 p = local_position - margin;
57
+ float radius = p.y < original_size.y/2.0 ? (p.x < original_size.x/2.0 ? radii.x : radii.y) : (p.x < original_size.x/2.0 ? radii.w : radii.z);
58
+ radius = clamp(radius, 0.0, min(original_size.x,original_size.y)/2.0);
59
+ vec2 q = abs(p - original_size/2.0) - original_size/2.0 + radius;
60
+ float distance = length(max(q,0.0)) + min(max(q.x,q.y),0.0) - radius;
61
+ float sigma = center_kind.z;
62
+ float coverage = dash == 1.0
63
+ ? clamp(0.5 - distance, 0.0, 1.0) * (sigma == 0.0 ? clamp(distance + spread + 0.5, 0.0, 1.0) : 0.5 + 0.5 * approx_erf((distance + spread) / (sigma * 1.41421356237)))
64
+ : sigma == 0.0 ? clamp(0.5 - distance + spread, 0.0, 1.0) : 0.5 - 0.5 * approx_erf((distance - spread) / (sigma * 1.41421356237));
65
+ fill.a *= coverage;
44
66
  } else if (center_kind.w == 1.0 || center_kind.w == 2.0) {
45
67
  vec2 uv = widths.xy + local_position / primitive_size * widths.zw;
46
68
  vec4 sample_color = texture(atlas, uv);
Binary file
@@ -23,6 +23,7 @@ layout(location = 6) out vec4 widths;
23
23
  layout(location = 7) out vec4 gradient;
24
24
  layout(location = 8) out vec4 center_kind;
25
25
  layout(location = 9) out float dash;
26
+ layout(location = 10) out float spread;
26
27
 
27
28
  void main() {
28
29
  vec2 corners[4] = vec2[4](vec2(0, 0), vec2(1, 0), vec2(0, 1), vec2(1, 1));
@@ -47,4 +48,5 @@ void main() {
47
48
  gradient = instance_gradient;
48
49
  center_kind = instance_center_kind;
49
50
  dash = instance_translation.w;
51
+ spread = instance_translation.z;
50
52
  }
Binary file
data/lib/zaniah/image.rb CHANGED
@@ -25,14 +25,14 @@ module Zaniah
25
25
  @texture = GPU::Texture.new(width, height, data: pixels)
26
26
  end
27
27
 
28
- attr_reader :frames
28
+ attr_reader :frames, :texture
29
29
 
30
30
  def request_layout(_cx)
31
31
  @layout_node = Layout::Node.new(style: @style, measure: ->(_width, _height) { [@texture.width, @texture.height] })
32
32
  end
33
33
 
34
34
  def paint(bounds, _state, _prepaint, cx)
35
- cx.scene.sprite(bounds.x, bounds.y, bounds.width, bounds.height, texture: @texture)
35
+ cx.scene.image(bounds.x, bounds.y, bounds.width, bounds.height, image: self, texture: @texture)
36
36
  end
37
37
 
38
38
  private
@@ -291,6 +291,7 @@ module Zaniah
291
291
  frame_started = MONOTONIC_CLOCK.call
292
292
  @text_system.scale_factor = @scale_factor if @text_system.respond_to?(:scale_factor=)
293
293
  @scene.clear
294
+ @scene.vector_sink.size = @content_size if @scene.vector_sink&.respond_to?(:size=)
294
295
  @text_runs.clear
295
296
  @text_system.start_frame if @text_system.respond_to?(:start_frame)
296
297
  @used_state.clear