From ce479299ebb5428cbb4e35b7d141cb6fec7909cc Mon Sep 17 00:00:00 2001 From: John Alanbrook Date: Thu, 8 Jan 2026 17:55:50 -0600 Subject: [PATCH] shape2d --- film2d.cm | 9 + sdl_gpu.cm | 204 ++++++++++++++- shaders/msl/shape2d.frag.msl | 472 +++++++++++++++++++++++++++++++++++ shape2d.cm | 154 ++++++++++++ 4 files changed, 838 insertions(+), 1 deletion(-) create mode 100644 shaders/msl/shape2d.frag.msl create mode 100644 shape2d.cm diff --git a/film2d.cm b/film2d.cm index 7b4edfcd..703fae99 100644 --- a/film2d.cm +++ b/film2d.cm @@ -203,6 +203,8 @@ film2d.render = function(params, backend) { commands.push({cmd: 'draw_text', drawable: batch.drawable}) else if (batch.type == 'texture_ref') commands.push({cmd: 'draw_texture_ref', drawable: batch.drawable}) + else if (batch.type == 'shape') + commands.push({cmd: 'draw_shape', drawable: batch.drawable}) } commands.push({cmd: 'end_render'}) @@ -301,6 +303,13 @@ function _batch_drawables(drawables) { } } } + } else if (d.type == 'shape') { + // Shapes are rendered individually (each has unique SDF params) + if (current) { + batches.push(current) + current = null + } + batches.push({type: 'shape', drawable: d}) } else { if (current) { batches.push(current) diff --git a/sdl_gpu.cm b/sdl_gpu.cm index 4fb8177f..bd154fca 100644 --- a/sdl_gpu.cm +++ b/sdl_gpu.cm @@ -36,6 +36,7 @@ var _crt_frag = null var _accumulator_frag = null var _text_sdf_frag = null var _text_msdf_frag = null +var _shape2d_frag = null // Pipelines var _pipelines = {} @@ -259,6 +260,18 @@ function _load_shaders() { }) } + var shape2d_frag_code = io.slurp("shaders/msl/shape2d.frag.msl") + if (shape2d_frag_code) { + _shape2d_frag = new gpu_mod.shader(_gpu, { + code: shape2d_frag_code, + stage: "fragment", + format: "msl", + entrypoint: "fragment_main", + num_uniform_buffers: 1, + num_samplers: 0 + }) + } + return true } @@ -582,6 +595,7 @@ function _create_pipelines() { }] } }) + } // Accumulator pipeline if (_blit_vert && _accumulator_frag) { _pipelines.accumulator = new gpu_mod.graphics_pipeline(_gpu, { @@ -604,7 +618,43 @@ function _create_pipelines() { color_targets: [{format: _swapchain_format, blend: {enabled: false}}] } }) - } } + } + + // Shape2D pipeline + if (_sprite_vert && _shape2d_frag) { + _pipelines.shape2d = new gpu_mod.graphics_pipeline(_gpu, { + vertex: _sprite_vert, + fragment: _shape2d_frag, + primitive: "triangle", + cull: "none", + face: "counter_clockwise", + fill: "fill", + vertex_buffer_descriptions: [{ + slot: 0, + pitch: 32, + input_rate: "vertex" + }], + vertex_attributes: [ + {location: 0, buffer_slot: 0, format: "float2", offset: 0}, + {location: 1, buffer_slot: 0, format: "float2", offset: 8}, + {location: 2, buffer_slot: 0, format: "float4", offset: 16} + ], + target: { + color_targets: [{ + format: _swapchain_format, + blend: { + enabled: true, + src_rgb: "src_alpha", + dst_rgb: "one_minus_src_alpha", + op_rgb: "add", + src_alpha: "one", + dst_alpha: "one_minus_src_alpha", + op_alpha: "add" + } + }] + } + }) + } } // ======================================================================== @@ -1155,6 +1205,10 @@ function _execute_commands(commands, window_size) { pending_draws.push(cmd) break + case 'draw_shape': + pending_draws.push(cmd) + break + case 'blit': // Flush pending draws first if (current_pass && pending_draws.length > 0) { @@ -1357,6 +1411,13 @@ function _flush_draws(cmd_buffer, pass, draws, camera, target) { // Render pre-rendered effect texture _render_texture_ref(cmd_buffer, pass, draw.drawable, camera, target) + } else if (draw.cmd == 'draw_shape') { + // Flush current batch + if (current_batch) _render_batch(cmd_buffer, pass, current_batch, camera, target) + current_batch = null + + // Render shape immediately + _render_shape(cmd_buffer, pass, draw.drawable, camera, target) } } @@ -1472,6 +1533,147 @@ function _render_texture_ref(cmd_buffer, pass, drawable, camera, target) { pass.draw_indexed(geom.index_count, 1, 0, 0, 0) } +function _render_shape(cmd_buffer, pass, drawable, camera, target) { + if (!_pipelines.shape2d) return + + var pos = drawable.pos || {x: 0, y: 0} + var w = drawable.width || 100 + var h = drawable.height || 100 + var ax = drawable.anchor_x != null ? drawable.anchor_x : 0.5 + var ay = drawable.anchor_y != null ? drawable.anchor_y : 0.5 + + // Calculate padding required for stroke and feather + var stroke_thickness = drawable.stroke_thickness || 0 + var feather = drawable.feather != null ? drawable.feather : 0.5 + + // Resolve stroke alignment (0=inside, 0.5=center, 1=outside) + var stroke_aligns = {inside: 0, center: 0.5, outside: 1} + var sa = drawable.stroke_align in stroke_aligns ? stroke_aligns[drawable.stroke_align] : 0.5 + + var pad = feather + if (stroke_thickness > 0) { + if (sa > 0.75) pad += stroke_thickness // Outside + else if (sa > 0.25) pad += stroke_thickness * 0.5 // Center + // Inside adds 0 + } + + // Expand quad by padding + var x = pos.x - w * ax - pad + var y = pos.y - h * ay - pad + var qw = w + pad * 2 + var qh = h + pad * 2 + + // Expand UVs to match padding (p calculation depends on this) + // logical size is w, h. padded size is qw, qh. + // 0..1 maps to w, h. + // We need UVs such that (uv - 0.5) * w spans the padded area logic. + // u=0 -> -0.5 * w = left edge of shape + // target left edge is -pad relative to shape left edge. + // So we need uv such that (uv - 0.5) * w = -w/2 - pad + // uv * w - w/2 = -w/2 - pad + // uv * w = -pad => uv = -pad / w + var u0 = -pad / w + var v0 = -pad / h + var u1 = 1.0 + pad / w + var v1 = 1.0 + pad / h + + var fill = drawable.fill || {r: 1, g: 1, b: 1, a: 1} + var opacity = drawable.opacity != null ? drawable.opacity : 1 + + // Vertex data: pos(2) + uv(2) + color(4) = 8 floats = 32 bytes per vertex + var vertex_data = new blob_mod(4 * 32) + var index_data = new blob_mod(6 * 2) + + // v0: bottom-left + vertex_data.wf(x); vertex_data.wf(y) + vertex_data.wf(u0); vertex_data.wf(v1) + vertex_data.wf(1); vertex_data.wf(1); vertex_data.wf(1); vertex_data.wf(1) + + // v1: bottom-right + vertex_data.wf(x + qw); vertex_data.wf(y) + vertex_data.wf(u1); vertex_data.wf(v1) + vertex_data.wf(1); vertex_data.wf(1); vertex_data.wf(1); vertex_data.wf(1) + + // v2: top-right + vertex_data.wf(x + qw); vertex_data.wf(y + qh) + vertex_data.wf(u1); vertex_data.wf(v0) + vertex_data.wf(1); vertex_data.wf(1); vertex_data.wf(1); vertex_data.wf(1) + + // v3: top-left + vertex_data.wf(x); vertex_data.wf(y + qh) + vertex_data.wf(u0); vertex_data.wf(v0) + vertex_data.wf(1); vertex_data.wf(1); vertex_data.wf(1); vertex_data.wf(1) + + // Indices + index_data.w16(0); index_data.w16(1); index_data.w16(2) + index_data.w16(0); index_data.w16(2); index_data.w16(3) + + // Upload geometry + var vb_size = 4 * 32 + var ib_size = 6 * 2 + + var vb = new gpu_mod.buffer(_gpu, {size: vb_size, vertex: true}) + var ib = new gpu_mod.buffer(_gpu, {size: ib_size, index: true}) + + var vb_transfer = new gpu_mod.transfer_buffer(_gpu, {size: vb_size, usage: "upload"}) + var ib_transfer = new gpu_mod.transfer_buffer(_gpu, {size: ib_size, usage: "upload"}) + + vb_transfer.copy_blob(_gpu, stone(vertex_data)) + ib_transfer.copy_blob(_gpu, stone(index_data)) + + var copy_cmd = _gpu.acquire_cmd_buffer() + var copy = copy_cmd.copy_pass() + copy.upload_to_buffer({transfer_buffer: vb_transfer, offset: 0}, {buffer: vb, offset: 0, size: vb_size}, false) + copy.upload_to_buffer({transfer_buffer: ib_transfer, offset: 0}, {buffer: ib, offset: 0, size: ib_size}, false) + copy.end() + copy_cmd.submit() + + // Build camera matrix + var proj = _build_camera_matrix(camera, target.width, target.height) + + // Build shape uniforms - must match ShapeParams struct in shader + // Total size: 112 bytes + var stroke = drawable.stroke || {r: 0, g: 0, b: 0, a: 0} + var shape_types = {rect: 0, circle: 1, ellipse: 2, pill: 3} + var shape_type = shape_types[drawable.shape_type] || 0 + + var u_data = new blob_mod(112) + u_data.wf(fill.r); u_data.wf(fill.g); u_data.wf(fill.b); u_data.wf(fill.a) // fill_color (16) + u_data.wf(stroke.r); u_data.wf(stroke.g); u_data.wf(stroke.b); u_data.wf(stroke.a) // stroke_color (16) + u_data.wf(w); u_data.wf(h) // size (8) - PASS LOGICAL SIZE + u_data.wf(drawable.radius || 0) // radius (4) + u_data.wf(feather) // feather (4) + u_data.wf(stroke_thickness) // stroke_thickness (4) + u_data.wf(sa) // stroke_align (4) + u_data.w32(shape_type) // shape_type (4) + u_data.w32(0) // corner_style (4) + u_data.wf(drawable.dash_len || 0) // dash_len (4) + u_data.wf(drawable.gap_len || 0) // gap_len (4) + u_data.wf(drawable.dash_offset || 0) // dash_offset (4) + u_data.w32(0) // cap_type (4) + + // uv_transform (16) + if (drawable.uv && drawable.uv.scale) { + u_data.wf(drawable.uv.scale.x); u_data.wf(drawable.uv.scale.y) + u_data.wf(drawable.uv.offset.x); u_data.wf(drawable.uv.offset.y) + } else { + u_data.wf(1); u_data.wf(1); u_data.wf(0); u_data.wf(0) + } + + u_data.wf(drawable.uv && drawable.uv.rotate ? drawable.uv.rotate : 0) // uv_rotate (4) + u_data.w32(0) // has_texture (4) + u_data.wf(opacity) // opacity (4) + u_data.wf(0) // _pad (4) + + pass.bind_pipeline(_pipelines.shape2d) + pass.bind_vertex_buffers(0, [{buffer: vb, offset: 0}]) + pass.bind_index_buffer({buffer: ib, offset: 0}, 16) + pass.bind_fragment_samplers(0, [{texture: _white_texture, sampler: _sampler_linear}]) + cmd_buffer.push_vertex_uniform_data(0, proj) + cmd_buffer.push_fragment_uniform_data(0, stone(u_data)) + pass.draw_indexed(6, 1, 0, 0, 0) +} + function _render_text(cmd_buffer, pass, drawable, camera, target) { // Get font - support mode tag: 'bitmap', 'sdf', 'msdf' var font_path = drawable.font diff --git a/shaders/msl/shape2d.frag.msl b/shaders/msl/shape2d.frag.msl new file mode 100644 index 00000000..867f2f2a --- /dev/null +++ b/shaders/msl/shape2d.frag.msl @@ -0,0 +1,472 @@ +#include +using namespace metal; + +#define M_PI_F 3.14159265358979323846f + +struct VertexOut { + float4 position [[position]]; + float2 uv; + float4 color; +}; + +struct ShapeParams { + float4 fill_color; + float4 stroke_color; + float2 size; + float radius; + float feather; + float stroke_thickness; + float stroke_align; + int shape_type; // 0=rect (rounded via radius), 1=circle, 2=ellipse, 3=pill + int corner_style; + float dash_len; + float gap_len; + float dash_offset; + int cap_type; // 0=butt, 1=square-ish, 2=round-ish + float4 uv_transform; + float uv_rotate; + int has_texture; + float opacity; + float _pad; +}; + +// -------- SDFs -------- + +float sdf_rounded_rect(float2 p, float2 b, float r) { + float2 q = abs(p) - b + r; + return min(max(q.x, q.y), 0.0f) + length(max(q, 0.0f)) - r; +} + +float sdf_circle(float2 p, float r) { + return length(p) - r; +} + +float sdf_ellipse(float2 p, float2 ab) { + float2 pn = p / ab; + float d = length(pn) - 1.0f; + return d * min(ab.x, ab.y); +} + +float sdf_pill(float2 p, float2 b) { + float r = min(b.x, b.y); + float2 q = abs(p) - float2(b.x - r, b.y - r); + return length(max(q, 0.0f)) + min(max(q.x, q.y), 0.0f) - r; +} + +float compute_sdf(float2 p, float2 half_size, int shape_type, float radius) { + if (shape_type == 0) { + float r = min(radius, min(half_size.x, half_size.y)); + return sdf_rounded_rect(p, half_size, r); + } + else if (shape_type == 1) { + float r = min(half_size.x, half_size.y); + return sdf_circle(p, r); + } + else if (shape_type == 2) { + return sdf_ellipse(p, half_size); + } + else { + return sdf_pill(p, half_size); + } +} + +// -------- Helpers -------- + +float wrap_angle_0_2pi(float a) { + float w = fmod(a, 2.0f * M_PI_F); + if (w < 0.0f) w += 2.0f * M_PI_F; + return w; +} + +// Project arbitrary p to ellipse boundary (stable and cheap) +// ab are ellipse radii (half_size.x, half_size.y) +float2 ellipse_project_boundary(float2 p, float2 ab) { + float2 safe_ab = max(ab, float2(1e-6f)); + float2 pn = p / safe_ab; + float len_pn = length(pn); + if (len_pn < 1e-6f) return float2(ab.x, 0.0f); + float2 dir = pn / len_pn; + return dir * ab; +} + +// Numeric arc length from 0..theta for ellipse with radii a,b using midpoint rule. +float ellipse_arclen_0_theta(float a, float b, float theta) { + const int N = 12; + float t = 0.0f; + float dt = theta / float(N); + float sum = 0.0f; + for (int i = 0; i < N; i++) { + float tm = t + 0.5f * dt; + float s = sin(tm); + float c = cos(tm); + float ds = sqrt(a * a * s * s + b * b * c * c); + sum += ds * dt; + t += dt; + } + return sum; +} + +float ellipse_perimeter_ramanujan(float a, float b) { + float apb = a + b; + float h = (a - b) * (a - b) / max(apb * apb, 1e-6f); + return M_PI_F * apb * (1.0f + (3.0f * h) / (10.0f + sqrt(max(4.0f - 3.0f * h, 1e-6f)))); +} + +// Rounded-rect boundary projection (for stable dash phase across stroke width) +float2 rounded_rect_project_boundary(float2 p, float2 half_size, float r) { + float2 inner = max(half_size - r, float2(0.0f)); + float2 ap = abs(p); + + if (ap.x > inner.x && ap.y > inner.y && r > 0.0f) { + float sx = (p.x < 0.0f) ? -1.0f : 1.0f; + float sy = (p.y < 0.0f) ? -1.0f : 1.0f; + float2 c = float2(inner.x * sx, inner.y * sy); + float2 v = p - c; + float lv = length(v); + if (lv < 1e-6f) return c + float2(r, 0.0f); + return c + v * (r / lv); + } + + float dx = half_size.x - ap.x; + float dy = half_size.y - ap.y; + + if (dx < dy) { + float sx = (p.x < 0.0f) ? -1.0f : 1.0f; + return float2(sx * half_size.x, clamp(p.y, -inner.y, inner.y)); + } + float sy = (p.y < 0.0f) ? -1.0f : 1.0f; + return float2(clamp(p.x, -inner.x, inner.x), sy * half_size.y); +} + +// Rounded-rect perimeter distance along boundary, starting at +X mid, CCW. +float rounded_rect_dist_along(float2 pb, float2 half_size, float r) { + float2 inner = max(half_size - r, float2(0.0f)); + float ix = inner.x; + float iy = inner.y; + + float seg_right_up = iy; + float seg_arc = 0.5f * M_PI_F * r; + float seg_top = 2.0f * ix; + float seg_left = 2.0f * iy; + float seg_bottom = 2.0f * ix; + + float ax = abs(pb.x); + float ay = abs(pb.y); + + // Right edge + if (pb.x > 0.0f && ay <= iy + 1e-5f) { + if (pb.y >= 0.0f) return pb.y; + float tail = seg_right_up + seg_arc + seg_top + seg_arc + seg_left + seg_arc + seg_bottom + seg_arc; + return tail + (pb.y + iy); + } + + // Top edge + if (pb.y > 0.0f && ax <= ix + 1e-5f) { + float s0 = seg_right_up + seg_arc; + return s0 + (ix - pb.x); + } + + // Left edge + if (pb.x < 0.0f && ay <= iy + 1e-5f) { + float s0 = seg_right_up + seg_arc + seg_top + seg_arc; + return s0 + (iy - pb.y); + } + + // Bottom edge + if (pb.y < 0.0f && ax <= ix + 1e-5f) { + float s0 = seg_right_up + seg_arc + seg_top + seg_arc + seg_left + seg_arc; + return s0 + (pb.x + ix); + } + + // Corners + if (pb.x > 0.0f && pb.y > 0.0f) { + float2 c = float2(ix, iy); + float2 v = pb - c; + float a = wrap_angle_0_2pi(atan2(v.y, v.x)); + float local = clamp(a, 0.0f, 0.5f * M_PI_F); + return seg_right_up + r * local; + } + if (pb.x < 0.0f && pb.y > 0.0f) { + float2 c = float2(-ix, iy); + float2 v = pb - c; + float a = wrap_angle_0_2pi(atan2(v.y, v.x)); + float local = clamp(M_PI_F - a, 0.0f, 0.5f * M_PI_F); + float s0 = seg_right_up + seg_arc + seg_top; + return s0 + r * local; + } + if (pb.x < 0.0f && pb.y < 0.0f) { + float2 c = float2(-ix, -iy); + float2 v = pb - c; + float a = wrap_angle_0_2pi(atan2(v.y, v.x)); + float local = clamp(a - M_PI_F, 0.0f, 0.5f * M_PI_F); + float s0 = seg_right_up + seg_arc + seg_top + seg_arc + seg_left; + return s0 + r * local; + } + + // Bottom-right + { + float2 c = float2(ix, -iy); + float2 v = pb - c; + float a = wrap_angle_0_2pi(atan2(v.y, v.x)); + float local = 0.0f; + if (a >= 1.5f * M_PI_F) local = clamp(a - 1.5f * M_PI_F, 0.0f, 0.5f * M_PI_F); + float s0 = seg_right_up + seg_arc + seg_top + seg_arc + seg_left + seg_arc + seg_bottom; + return s0 + r * local; + } +} + +// -------- Pill helpers -------- + +float pill_perimeter(float2 half_size) { + float r = min(half_size.x, half_size.y); + float major = max(half_size.x, half_size.y); + float straight = max(major - r, 0.0f); + return 4.0f * straight + 2.0f * M_PI_F * r; +} + +float2 pill_project_boundary(float2 p, float2 half_size) { + float r = min(half_size.x, half_size.y); + bool horiz = (half_size.x >= half_size.y); + if (horiz) { + float cx = max(half_size.x - r, 0.0f); + float2 c = float2((p.x >= 0.0f ? cx : -cx), 0.0f); + float2 v = p - c; + float lv = length(v); + if (lv < 1e-6f) return c + float2(r, 0.0f); + return c + v * (r / lv); + } + float cy = max(half_size.y - r, 0.0f); + float2 c = float2(0.0f, (p.y >= 0.0f ? cy : -cy)); + float2 v = p - c; + float lv = length(v); + if (lv < 1e-6f) return c + float2(r, 0.0f); + return c + v * (r / lv); +} + +float pill_dist_along(float2 pb, float2 half_size) { + float r = min(half_size.x, half_size.y); + bool horiz = (half_size.x >= half_size.y); + + if (horiz) { + float cx = max(half_size.x - r, 0.0f); + + if (pb.x > cx - 1e-5f) { + float2 c = float2(cx, 0.0f); + float2 v = pb - c; + float a = wrap_angle_0_2pi(atan2(v.y, v.x)); + float local = clamp(a, 0.0f, M_PI_F); + return r * local; + } + + if (abs(pb.y - r) <= 1e-3f && abs(pb.x) <= cx + 1e-3f) { + float s0 = M_PI_F * r; + return s0 + (cx - pb.x); + } + + if (pb.x < -cx + 1e-5f) { + float2 c = float2(-cx, 0.0f); + float2 v = pb - c; + float a = wrap_angle_0_2pi(atan2(v.y, v.x)); + float local = clamp(a - M_PI_F, 0.0f, M_PI_F); + float s0 = M_PI_F * r + 2.0f * cx; + return s0 + r * local; + } + + float s0 = M_PI_F * r + 2.0f * cx + M_PI_F * r; + return s0 + (pb.x + cx); + } + + float cy = max(half_size.y - r, 0.0f); + + if (pb.y > cy - 1e-5f) { + float2 c = float2(0.0f, cy); + float2 v = pb - c; + float a = wrap_angle_0_2pi(atan2(v.y, v.x)); + float local = clamp(a, 0.0f, M_PI_F); + return r * local; + } + + if (abs(pb.x + r) <= 1e-3f && abs(pb.y) <= cy + 1e-3f) { + float s0 = M_PI_F * r; + return s0 + (cy - pb.y); + } + + if (pb.y < -cy + 1e-5f) { + float2 c = float2(0.0f, -cy); + float2 v = pb - c; + float a = wrap_angle_0_2pi(atan2(v.y, v.x)); + float local = clamp(a - M_PI_F, 0.0f, M_PI_F); + float s0 = M_PI_F * r + 2.0f * cy; + return s0 + r * local; + } + + float s0 = M_PI_F * r + 2.0f * cy + M_PI_F * r; + return s0 + (pb.y + cy); +} + +// -------- Perimeter mapping for dashes -------- + +float perimeter_dist_along(float2 p, float2 half_size, int shape_type, float radius) { + if (shape_type == 1) { + float r = min(half_size.x, half_size.y); + float2 pb = (length(p) < 1e-6f) ? float2(r, 0.0f) : normalize(p) * r; + float theta = wrap_angle_0_2pi(atan2(pb.y, pb.x)); + return r * theta; + } + + if (shape_type == 2) { + float a = max(half_size.x, 1e-6f); + float b = max(half_size.y, 1e-6f); + float2 pb = ellipse_project_boundary(p, float2(a, b)); + float theta = wrap_angle_0_2pi(atan2(pb.y / b, pb.x / a)); + return ellipse_arclen_0_theta(a, b, theta); + } + + if (shape_type == 3) { + float2 pb = pill_project_boundary(p, half_size); + return pill_dist_along(pb, half_size); + } + + float r = min(radius, min(half_size.x, half_size.y)); + float2 pb = rounded_rect_project_boundary(p, half_size, r); + return rounded_rect_dist_along(pb, half_size, r); +} + +float perimeter_total(float2 half_size, int shape_type, float radius) { + if (shape_type == 1) { + float r = min(half_size.x, half_size.y); + return 2.0f * M_PI_F * r; + } + if (shape_type == 2) { + float a = max(half_size.x, 1e-6f); + float b = max(half_size.y, 1e-6f); + return ellipse_perimeter_ramanujan(a, b); + } + if (shape_type == 3) { + return pill_perimeter(half_size); + } + float r = min(radius, min(half_size.x, half_size.y)); + float2 inner = max(half_size - r, float2(0.0f)); + return 4.0f * (inner.x + inner.y) + 2.0f * M_PI_F * r; +} + +float dash_mask_perimeter(float dist_along, float perimeter, + float dash_len, float gap_len, float dash_offset, + int cap_type, float aa) { + if (dash_len <= 0.0f) return 1.0f; + + float pattern_len = max(dash_len + gap_len, 1e-6f); + float s = dist_along + dash_offset; + + float pwrap = max(perimeter, 1e-6f); + float s_wrap = fmod(s, pwrap); + if (s_wrap < 0.0f) s_wrap += pwrap; + + float pos = fmod(s_wrap, pattern_len); + if (pos < 0.0f) pos += pattern_len; + + if (cap_type == 0) { + return pos < dash_len ? 1.0f : 0.0f; + } + + if (cap_type == 1) { + float edge = dash_len; + return 1.0f - smoothstep(edge - aa, edge + aa, pos); + } + + float half_dash = dash_len * 0.5f; + float center = half_dash; + float d = abs(pos - center); + return smoothstep(half_dash + aa, half_dash - aa, d); +} + +// -------- Fragment -------- + +fragment float4 fragment_main(VertexOut in [[stage_in]], + constant ShapeParams& params [[buffer(0)]], + texture2d fill_texture [[texture(0)]], + sampler smp [[sampler(0)]]) { + float2 uv = in.uv; + + float2 p = (uv - 0.5f) * params.size; + float2 half_size = params.size * 0.5f; + + float d = compute_sdf(p, half_size, params.shape_type, params.radius); + + float aa = max(params.feather, fwidth(d)); + + float fill_alpha = 0.0f; + float stroke_alpha = 0.0f; + + if (params.stroke_thickness > 0.0f) { + float inner_offset, outer_offset; + + if (params.stroke_align < 0.25f) { + inner_offset = -params.stroke_thickness; + outer_offset = 0.0f; + } + else if (params.stroke_align > 0.75f) { + inner_offset = 0.0f; + outer_offset = params.stroke_thickness; + } + else { + inner_offset = -params.stroke_thickness * 0.5f; + outer_offset = params.stroke_thickness * 0.5f; + } + + float fill_d = d - inner_offset; + fill_alpha = 1.0f - smoothstep(-aa, aa, fill_d); + + float start = smoothstep(-aa, aa, d - inner_offset); + float end = 1.0f - smoothstep(-aa, aa, d - outer_offset); + float in_stroke = start * end; + + float perim = perimeter_total(half_size, params.shape_type, params.radius); + float dist_along = perimeter_dist_along(p, half_size, params.shape_type, params.radius); + float dash = dash_mask_perimeter(dist_along, perim, + params.dash_len, params.gap_len, params.dash_offset, + params.cap_type, aa); + + stroke_alpha = in_stroke * dash; + } + else { + fill_alpha = 1.0f - smoothstep(-aa, aa, d); + } + + float4 fill_col = params.fill_color; + if (params.has_texture > 0) { + float2 tex_uv = uv; + tex_uv = tex_uv * params.uv_transform.xy + params.uv_transform.zw; + + if (abs(params.uv_rotate) > 0.001f) { + float2 center = float2(0.5f, 0.5f); + tex_uv -= center; + float c = cos(params.uv_rotate); + float s = sin(params.uv_rotate); + tex_uv = float2(tex_uv.x * c - tex_uv.y * s, tex_uv.x * s + tex_uv.y * c); + tex_uv += center; + } + + float4 tex_col = fill_texture.sample(smp, tex_uv); + fill_col *= tex_col; + } + + float4 fill_out = fill_col; + fill_out.a *= fill_alpha; + + float4 stroke_out = params.stroke_color; + stroke_out.a *= stroke_alpha; + + float sa = stroke_out.a; + float fa = fill_out.a; + + float3 rgb_premul = stroke_out.rgb * sa + fill_out.rgb * fa * (1.0f - sa); + float a = sa + fa * (1.0f - sa); + + float4 result = (a > 1e-6f) ? float4(rgb_premul / a, a) : float4(0.0f); + + result *= in.color; + result.a *= params.opacity; + + return result; +} diff --git a/shape2d.cm b/shape2d.cm new file mode 100644 index 00000000..31494b87 --- /dev/null +++ b/shape2d.cm @@ -0,0 +1,154 @@ +var film2d = use('film2d') + +var shape_proto = { + type: 'shape', + + set_pos: function(x, y) { + this.pos.x = x + this.pos.y = y + return this + }, + + set_groups: function(groups) { + var old_groups = this.groups + this.groups = groups + film2d.reindex(this._id, old_groups, groups) + return this + }, + + add_group: function(group) { + if (this.groups.indexOf(group) < 0) { + this.groups.push(group) + film2d.index_group(this._id, group) + } + return this + }, + + remove_group: function(group) { + var idx = this.groups.indexOf(group) + if (idx >= 0) { + this.groups.splice(idx, 1) + film2d.unindex_group(this._id, group) + } + return this + }, + + destroy: function() { + film2d.unregister(this._id) + } +} + +var defaults = { + type: 'shape', + shape_type: 'rect', // 'rect', 'circle', 'ellipse', 'pill' + + // routing + plane: 'default', + layer: 0, + groups: [], + visible: true, + + // transform + pos: {x: 0, y: 0}, + anchor_x: 0.5, + anchor_y: 0.5, + rotation: 0, + + // geometry + width: 100, + height: 100, + + // corner / radius controls (rect/pill) + radius: 0, + corner_style: 'round', // 'round', 'bevel', 'scoop', 'notch', 'square' + + // edge / coverage + feather: 0, + stroke_thickness: 0, + stroke_align: 'center', // 'inside', 'center', 'outside' + + // dashed stroke + dash_len: 0, + gap_len: 0, + dash_offset: 0, + cap: 'butt', // 'butt', 'square', 'round' + join: 'miter', // 'miter', 'bevel', 'round' + miter_limit: 4, + + // fill/stroke colors + fill: {r: 1, g: 1, b: 1, a: 1}, + stroke: {r: 0, g: 0, b: 0, a: 0}, + + // blending + blend: 'alpha', // 'alpha', 'add', 'mul', 'screen' + opacity: 1, + + // texture fill + fill_tex: null, + uv: { + space: 'local', // 'local', 'screen' + scale: {x: 1, y: 1}, + offset: {x: 0, y: 0}, + rotate: 0 + }, + sampler: null +} + +function make_shape(shape_type, props) { + var data = {} + for (var k in defaults) { + var v = defaults[k] + if (is_object(v) && !is_array(v)) { + data[k] = {} + for (var kk in v) data[k][kk] = v[kk] + } else { + data[k] = v + } + } + + data.shape_type = shape_type + + // Apply user props (deep merge for objects) + for (var k in props) { + var v = props[k] + if (is_object(v) && !is_array(v) && is_object(data[k])) { + for (var kk in v) data[k][kk] = v[kk] + } else { + data[k] = v + } + } + + // Ensure groups is array + if (!data.groups) data.groups = [] + if (is_text(data.groups)) data.groups = [data.groups] + + var s = meme(shape_proto, data) + film2d.register(s) + return s +} + +var shape2d = { + rect: function(props) { + return make_shape('rect', props) + }, + + circle: function(props) { + // For circle, width == height == diameter + var p = props || {} + if (p.radius != null && p.width == null) { + p.width = p.radius * 2 + p.height = p.radius * 2 + } + return make_shape('circle', p) + }, + + ellipse: function(props) { + return make_shape('ellipse', props) + }, + + pill: function(props) { + return make_shape('pill', props) + } +} + +return shape2d