From 2f7774b83f3e5f2de5146831a5b7ef9ada5808d8 Mon Sep 17 00:00:00 2001 From: lisyarus Date: Tue, 21 Jul 2026 18:08:56 +0300 Subject: [PATCH] SDF-based + arbitrary affine transformations vector graphics experiments (gfx::painter2) --- examples/painter.cpp | 67 ++++ libs/gfx/include/psemek/gfx/painter2.hpp | 64 +++ libs/gfx/source/painter2.cpp | 473 +++++++++++++++++++++++ 3 files changed, 604 insertions(+) create mode 100644 examples/painter.cpp create mode 100644 libs/gfx/include/psemek/gfx/painter2.hpp create mode 100644 libs/gfx/source/painter2.cpp diff --git a/examples/painter.cpp b/examples/painter.cpp new file mode 100644 index 00000000..345a0169 --- /dev/null +++ b/examples/painter.cpp @@ -0,0 +1,67 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace psemek; + +struct painter_app + : app::application_base +{ + painter_app(options const &, context const &) + {} + + void update() override + { + [[maybe_unused]] float const dt = frame_clock_.restart().count(); + + angle_ += dt; + } + + void present() override + { + gl::Viewport(0, 0, state().size[0], state().size[1]); + + gl::ClearColor(1.f, 1.f, 1.f, 1.f); + gl::Clear(gl::COLOR_BUFFER_BIT); + + math::box view_box{{{0.f, state().size[0]}, {state().size[1], 0.f}}}; + + auto builder = painter_.begin_frame(state().size); + builder->push_matrix(math::orthographic(view_box).transform()); + builder->fill_circle({200.f, 200.f}, 100.f, {.color = {0, 0, 255, 255}}); + builder->push_matrix(math::translation({200.f, 600.f}).transform()); + builder->push_matrix(math::shear(0, 1, 1.f).transform()); + builder->stroke_square({0.f, 0.f}, 100.f, math::rad(45.f), {.color = {255, 127, 255, 255}, .width = 25.f}); + builder->pop_matrix(); + builder->pop_matrix(); + builder->stroke_ellipse({400.f, 400.f}, {50.f, 5.f}, angle_, {.color = {255, 0, 0, 255}, .width = 2.f}); + builder->stroke_ellipse({960.f, 540.f}, {400.f, 20.f}, angle_ * 0.1f, {.color = {255, 0, 0, 255}, .width = 20.f}); + builder->flush(); + } + +private: + random::generator rng_; + + util::clock<> frame_clock_; + + float angle_ = 0.f; + + gfx::painter2 painter_; +}; + +namespace psemek::app +{ + + std::unique_ptr make_application_factory() + { + return default_application_factory({.name = "Painter example", .multisampling = 0}); + } + +} diff --git a/libs/gfx/include/psemek/gfx/painter2.hpp b/libs/gfx/include/psemek/gfx/painter2.hpp new file mode 100644 index 00000000..0880caa5 --- /dev/null +++ b/libs/gfx/include/psemek/gfx/painter2.hpp @@ -0,0 +1,64 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include + +namespace psemek::gfx +{ + + struct painter2 + { + painter2(); + ~painter2(); + + using transform_type = math::affine_transform; + + struct fill_style + { + gfx::color_rgba color; + }; + + struct stroke_style + { + gfx::color_rgba color; + float width; + }; + + struct frame_builder + { + // Matrix stack (NB: default transform is OpenGL standart [-1..1]^2) + virtual void push_matrix(transform_type const & matrix) = 0; + virtual void pop_matrix() = 0; + virtual void reset_matrix() = 0; + + // Fill primitives + virtual void fill_circle(math::point const & center, float radius, fill_style const & style) = 0; + virtual void fill_ellipse(math::point const & center, math::vector const & radii, float rotation, fill_style const & style) = 0; + virtual void fill_square(math::point const & center, float extent, float rotation, fill_style const & style) = 0; + virtual void fill_rectangle(math::point const & center, math::vector const & extent, float rotation, fill_style const & style) = 0; + + // Stroke primitives + virtual void stroke_circle(math::point const & center, float radius, stroke_style const & style) = 0; + virtual void stroke_ellipse(math::point const & center, math::vector const & radii, float rotation, stroke_style const & style) = 0; + virtual void stroke_square(math::point const & center, float extent, float rotation, stroke_style const & style) = 0; + virtual void stroke_rectangle(math::point const & center, math::vector const & extent, float rotation, stroke_style const & style) = 0; + + // Render everything on screen (called automatically in destructor) + virtual void flush() = 0; + + virtual ~frame_builder() {} + }; + + std::unique_ptr begin_frame(math::vector viewport_size); + + private: + psemek_declare_pimpl + }; + +} diff --git a/libs/gfx/source/painter2.cpp b/libs/gfx/source/painter2.cpp new file mode 100644 index 00000000..1e9c4212 --- /dev/null +++ b/libs/gfx/source/painter2.cpp @@ -0,0 +1,473 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +namespace psemek::gfx +{ + + namespace + { + + // MVP (Model-View-Projection) matrix is assumed to be always a 2D affine transform + // Thus, it can be passed as 2x 3D vectors (the rows of the corresponding 2x3 affine matrix) + + // MVPW (Model-View-Projection-Window) matrix composes MVP with the viewport transform ([-1..1] -> [0..width] in pixels) + // Its inverse is used in gradient computations for analytical SDF rendering + + char const sdf_vertex_common[] = R"( +uniform vec2 u_viewport_size; + +layout (location = 0) in vec3 in_mvp_0; +layout (location = 1) in vec3 in_mvp_1; +layout (location = 2) in vec2 in_mvpw_inv_0; +layout (location = 3) in vec2 in_mvpw_inv_1; +layout (location = 4) in vec4 in_color; +layout (location = 5) in float in_half_width; + +mat2 adj(mat2 m) +{ + return mat2( + m[1][1], -m[1][0], + -m[0][1], m[0][0] + ); +} + +out mat2 mvpw_inv_t; +out mat2 mvpw_t; +out vec2 vertex; +out vec4 color; +out float half_width; + +mat2x3 mvp_t = mat2x3(in_mvp_0, in_mvp_1); +mat2 w = mat2(u_viewport_size.x / 2.0, 0.0, 0.0, - u_viewport_size.y / 2.0); +mat2 mvpw_adj_t = adj(mat2(mvp_t) * w); + +vec2 extend(vec2 n1, vec2 n2) +{ + // Compute the screen-space normals + n1 = normalize(n1 * mvpw_adj_t); + n2 = normalize(n2 * mvpw_adj_t); + + // Find the screen-space bisector for n1 and n2 + vec2 n = (n1 + n2) / (1.0 + dot(n1, n2)); + + // Transform to object space + return n * mvpw_inv_t; +} + +vec2 get_vertex(); + +void main() +{ + mvpw_t = mat2(mvp_t) * w; + mvpw_inv_t = mat2(in_mvpw_inv_0, in_mvpw_inv_1); + + vertex = get_vertex(); + color = in_color; + half_width = in_half_width; + gl_Position = vec4(vec3(vertex, 1.0) * mvp_t, 0.0, 1.0); +} +)"; + + char const sdf_fill_fragment_common[] = R"( +in vec2 vertex; +in mat2 mvpw_inv_t; +in vec4 color; + +layout (location = 0) out vec4 out_color; + +// (grad_x, grad_y, distance) +vec3 eval_sdf(vec2 p); + +void main() +{ + vec3 sdf = eval_sdf(vertex); + + vec2 d_grad_screen = mvpw_inv_t * sdf.xy; + float d_grad_screen_length = length(d_grad_screen); + float aa = d_grad_screen_length * sqrt(0.5); + + out_color = color * smoothstep(aa, -aa, sdf.z); +} +)"; + + char const quad_vs[] = R"( +const vec2 QUAD_VERTICES[6] = vec2[6]( + vec2(-1.0, -1.0), + vec2( 1.0, -1.0), + vec2(-1.0, 1.0), + vec2(-1.0, 1.0), + vec2( 1.0, -1.0), + vec2( 1.0, 1.0) +); + +vec2 get_vertex() +{ + vec2 v = QUAD_VERTICES[gl_VertexID]; + + // Extend the quad in screen-space by half-width and an extra 1px for anti-aliasing + v += (in_half_width + 1.0) * extend(vec2(v.x, 0.0), vec2(0.0, v.y)); + + return v; +} +)"; + + char const circle_sdf[] = R"( +vec3 eval_sdf(vec2 p) +{ + float l = length(p); + return vec3(p / l, l - 1.0); +} +)"; + + char const square_sdf[] = R"( +vec3 eval_sdf(vec2 p) +{ + vec2 delta = abs(p) - vec2(1.0); + + if (delta.x > 0.0 || delta.y > 0.0) { + vec2 g = max(delta, vec2(0.0)); + float l = length(g); + return vec3(g * (sign(p) / l), l); + } else { + if (delta.x > delta.y) { + return vec3(sign(p.x), 0.0, delta.x); + } else { + return vec3(0.0, sign(p.y), delta.y); + } + } +} +)"; + + char const sdf_stroke_fragment_common[] = R"( +in vec2 vertex; +in mat2 mvpw_t; +in mat2 mvpw_inv_t; +in vec4 color; +in float half_width; + +layout (location = 0) out vec4 out_color; + +vec3 eval_sdf(vec2 p); + +void main() +{ + // Newton-Raphson iteration to find the closest point on the boundary + vec2 vertex_screen = vertex * mvpw_t; + vec2 p_screen = vertex_screen; + for (int i = 0; i < 4; ++i) + { + vec2 p_local = p_screen * mvpw_inv_t; + vec3 sdf_value = eval_sdf(p_local); + p_screen = p_local * mvpw_t; + vec2 d_grad_screen = mvpw_inv_t * sdf_value.xy; + p_screen -= (sdf_value.z / dot(d_grad_screen, d_grad_screen)) * d_grad_screen; + } + + float d = length(vertex_screen - p_screen); + float aa = sqrt(0.5); + + out_color = color * smoothstep(aa, -aa, d - half_width); +} +)"; + + gfx::shader_source const quad_vs_source{gl::sys::shader_prelude(), sdf_vertex_common, quad_vs}; + + using transform_type = painter2::transform_type; + using fill_style = painter2::fill_style; + using stroke_style = painter2::stroke_style; + + struct context + { + gfx::buffer instance_buffer; + + gfx::program fill_circle_program{quad_vs_source, {gl::sys::shader_prelude(), sdf_fill_fragment_common, circle_sdf}}; + gfx::program fill_square_program{quad_vs_source, {gl::sys::shader_prelude(), sdf_fill_fragment_common, square_sdf}}; + gfx::program stroke_circle_program{quad_vs_source, {gl::sys::shader_prelude(), sdf_stroke_fragment_common, circle_sdf}}; + gfx::program stroke_square_program{quad_vs_source, {gl::sys::shader_prelude(), sdf_stroke_fragment_common, square_sdf}}; + + gfx::array array; + + context(); + }; + + struct primitive_instance + { + transform_type mvp; + math::matrix mvpw_inv; + gfx::color_rgba color; + float half_width; // unused when filling + }; + + struct batch + { + gfx::program * program; + std::vector instances; + }; + + struct frame_builder_impl + : painter2::frame_builder + { + frame_builder_impl(context & context, math::vector viewport_size); + + void push_matrix(transform_type const & matrix) override; + void pop_matrix() override; + void reset_matrix() override; + + void fill_circle(math::point const & center, float radius, fill_style const & style) override; + void fill_ellipse(math::point const & center, math::vector const & radii, float rotation, fill_style const & style) override; + void fill_square(math::point const & center, float extent, float rotation, fill_style const & style) override; + void fill_rectangle(math::point const & center, math::vector const & extent, float rotation, fill_style const & style) override; + + void stroke_circle(math::point const & center, float radius, stroke_style const & style) override; + void stroke_ellipse(math::point const & center, math::vector const & radii, float rotation, stroke_style const & style) override; + void stroke_square(math::point const & center, float extent, float rotation, stroke_style const & style) override; + void stroke_rectangle(math::point const & center, math::vector const & extent, float rotation, stroke_style const & style) override; + + void flush() override; + + ~frame_builder_impl() override + { + flush(); + } + + private: + context & context_; + math::vector viewport_size_; + transform_type viewport_transform_; + + std::vector matrix_stack_; + + std::vector batches_; + + batch * get_batch(gfx::program * program); + + void add_ellipse(math::point const & center, math::vector const & radii, float rotation, fill_style const & style); + void add_rectangle(math::point const & center, math::vector const & extent, float rotation, fill_style const & style); + + void add_ellipse(math::point const & center, math::vector const & radii, float rotation, stroke_style const & style); + void add_rectangle(math::point const & center, math::vector const & extent, float rotation, stroke_style const & style); + + primitive_instance make_primitive(transform_type const & model, fill_style const & style); + primitive_instance make_primitive(transform_type const & model, stroke_style const & style); + + transform_type make_transform(math::point const & center, math::vector const & scale, float rotation); + std::pair> make_mvp(transform_type const & model); + }; + + context::context() + { + array.bind(); + instance_buffer.bind(); + gl::EnableVertexAttribArray(0); + gl::EnableVertexAttribArray(1); + gl::EnableVertexAttribArray(2); + gl::EnableVertexAttribArray(3); + gl::EnableVertexAttribArray(4); + gl::EnableVertexAttribArray(5); + gl::VertexAttribDivisor(0, 1); + gl::VertexAttribDivisor(1, 1); + gl::VertexAttribDivisor(2, 1); + gl::VertexAttribDivisor(3, 1); + gl::VertexAttribDivisor(4, 1); + gl::VertexAttribDivisor(5, 1); + gl::VertexAttribPointer(0, 3, gl::FLOAT, gl::FALSE, sizeof(primitive_instance), (char *)offsetof(primitive_instance, mvp)); + gl::VertexAttribPointer(1, 3, gl::FLOAT, gl::FALSE, sizeof(primitive_instance), (char *)offsetof(primitive_instance, mvp) + 3 * sizeof(float)); + gl::VertexAttribPointer(2, 2, gl::FLOAT, gl::FALSE, sizeof(primitive_instance), (char *)offsetof(primitive_instance, mvpw_inv)); + gl::VertexAttribPointer(3, 2, gl::FLOAT, gl::FALSE, sizeof(primitive_instance), (char *)offsetof(primitive_instance, mvpw_inv) + 2 * sizeof(float)); + gl::VertexAttribPointer(4, 4, gl::UNSIGNED_BYTE, gl::TRUE, sizeof(primitive_instance), (char *)offsetof(primitive_instance, color)); + gl::VertexAttribPointer(5, 1, gl::FLOAT, gl::FALSE, sizeof(primitive_instance), (char *)offsetof(primitive_instance, half_width)); + + gfx::array::null().bind(); + } + + frame_builder_impl::frame_builder_impl(context & context, math::vector viewport_size) + : context_(context) + , viewport_size_(viewport_size) + , viewport_transform_( + math::translation{math::cast(viewport_size) / 2.f}.transform() * + math::scale{math::pointwise_mult( + math::cast(viewport_size), + math::vector{0.5f, -0.5f} + )}.transform() + ) + { + matrix_stack_.push_back(transform_type::identity()); + } + + void frame_builder_impl::push_matrix(painter2::transform_type const & matrix) + { + matrix_stack_.push_back(matrix_stack_.back() * matrix); + } + + void frame_builder_impl::pop_matrix() + { + if (matrix_stack_.size() > 1) + matrix_stack_.pop_back(); + } + + void frame_builder_impl::reset_matrix() + { + matrix_stack_.resize(1); + } + + void frame_builder_impl::fill_circle(math::point const & center, float radius, fill_style const & style) + { + add_ellipse(center, {radius, radius}, 0.f, style); + } + + void frame_builder_impl::fill_ellipse(math::point const & center, math::vector const & radii, float rotation, fill_style const & style) + { + add_ellipse(center, radii, rotation, style); + } + + void frame_builder_impl::fill_square(math::point const & center, float extent, float rotation, fill_style const & style) + { + add_rectangle(center, {extent, extent}, rotation, style); + } + + void frame_builder_impl::fill_rectangle(math::point const & center, math::vector const & extent, float rotation, fill_style const & style) + { + add_rectangle(center, extent, rotation, style); + } + + void frame_builder_impl::stroke_circle(math::point const & center, float radius, stroke_style const & style) + { + add_ellipse(center, {radius, radius}, 0.f, style); + } + + void frame_builder_impl::stroke_ellipse(math::point const & center, math::vector const & radii, float rotation, stroke_style const & style) + { + add_ellipse(center, radii, rotation, style); + } + + void frame_builder_impl::stroke_square(math::point const & center, float extent, float rotation, stroke_style const & style) + { + add_rectangle(center, {extent, extent}, rotation, style); + } + + void frame_builder_impl::stroke_rectangle(math::point const & center, math::vector const & extent, float rotation, stroke_style const & style) + { + add_rectangle(center, extent, rotation, style); + } + + void frame_builder_impl::flush() + { + if (batches_.empty()) + return; + + gl::Enable(gl::BLEND); + gl::BlendEquation(gl::FUNC_ADD); + gl::BlendFunc(gl::ONE, gl::ONE_MINUS_SRC_ALPHA); + + auto const viewport_size_f = math::cast(viewport_size_); + + context_.array.bind(); + + for (auto const & batch : batches_) + { + if (batch.instances.empty()) continue; + + auto & program = *batch.program; + program.bind(); + program["u_viewport_size"] = viewport_size_f; + context_.instance_buffer.load(batch.instances); + gl::DrawArraysInstanced(gl::TRIANGLES, 0, 6, batch.instances.size()); + } + + gfx::array::null().bind(); + gfx::program::null().bind(); + + batches_.clear(); + } + + batch * frame_builder_impl::get_batch(gfx::program * program) + { + if (batches_.empty() || batches_.back().program != program) + batches_.push_back({.program = program, .instances = {}}); + return &batches_.back(); + } + + void frame_builder_impl::add_ellipse(math::point const & center, math::vector const & radii, float rotation, fill_style const & style) + { + get_batch(&context_.fill_circle_program)->instances.push_back(make_primitive(make_transform(center, radii, rotation), style)); + } + + void frame_builder_impl::add_rectangle(math::point const & center, math::vector const & extent, float rotation, fill_style const & style) + { + get_batch(&context_.fill_square_program)->instances.push_back(make_primitive(make_transform(center, extent, rotation), style)); + } + + void frame_builder_impl::add_ellipse(math::point const & center, math::vector const & radii, float rotation, stroke_style const & style) + { + get_batch(&context_.stroke_circle_program)->instances.push_back(make_primitive(make_transform(center, radii, rotation), style)); + } + + void frame_builder_impl::add_rectangle(math::point const & center, math::vector const & extent, float rotation, stroke_style const & style) + { + get_batch(&context_.stroke_square_program)->instances.push_back(make_primitive(make_transform(center, extent, rotation), style)); + } + + primitive_instance frame_builder_impl::make_primitive(transform_type const & model, fill_style const & style) + { + auto [mvp, mvpw_inv] = make_mvp(model); + + return { + .mvp = mvp, + .mvpw_inv = mvpw_inv, + .color = style.color, + .half_width = 0.f, + }; + } + + primitive_instance frame_builder_impl::make_primitive(transform_type const & model, stroke_style const & style) + { + auto [mvp, mvpw_inv] = make_mvp(model); + + return { + .mvp = mvp, + .mvpw_inv = mvpw_inv, + .color = style.color, + .half_width = style.width * 0.5f, + }; + } + + transform_type frame_builder_impl::make_transform(math::point const & center, math::vector const & scale, float rotation) + { + return math::translation(center - center.zero()).transform() * + math::plane_rotation(0, 1, rotation).transform() * + math::scale(scale).transform(); + } + + std::pair> frame_builder_impl::make_mvp(transform_type const & model) + { + auto mvp = matrix_stack_.back() * model; + auto mvpw_inv = math::inverse(viewport_transform_.linear_matrix() * mvp.linear_matrix()).value(); + return {mvp, mvpw_inv}; + } + + } + + struct painter2::impl + { + struct context context; + }; + + painter2::painter2() + : pimpl_(make_impl()) + {} + + painter2::~painter2() + {} + + std::unique_ptr painter2::begin_frame(math::vector viewport_size) + { + return std::make_unique(impl().context, viewport_size); + } + +}