SVG loading and rendering for raylib.
raysvg parses an SVG into a retained scene graph: geometry is tessellated once and
cached, then redrawn each frame through rlgl. Groups stay addressable after load, so you can
look elements up by id or class and drive them with per-element channels — transform,
pivot, opacity, display, z-index, reparenting, outline, and CSS variables — without
re-parsing or re-tessellating.
It works with stock raylib and with the custom backends used by rayact — rlvk (Vulkan), rlwg (WebGPU), rlmt (Metal).
RaysvgDoc *doc = RaysvgLoadFromFile("icon.svg");
while (!WindowShouldClose()) {
BeginDrawing();
ClearBackground(RAYWHITE);
RaysvgDraw(doc, (Rectangle){ 40, 40, 400, 400 });
EndDrawing();
}
RaysvgUnload(doc);Animate or recolour any element that has an id:
RaysvgHandle wheel = RaysvgGetElementById(doc, "wheel");
RaysvgSetTransform(doc, wheel, 0, 0, angle, 1.0f, 1.0f);
RaysvgSetVar(doc, "--accent", (Color){ 224, 83, 61, 255 });The full API is a single header: include/raysvg.h.
raysvg never links raylib itself. It needs raylib.h and rlgl.h at compile time, and the
rl* symbols resolve against whatever backend the host application links. That is what lets
one build of the source run on desktop GL, Vulkan, Metal and WebGPU.
cmake -S . -B build -DRAYSVG_RAYLIB_DIR=/path/to/raylib
cmake --build build -j
ctest --test-dir build --output-on-failureAs a subproject, add_subdirectory(raysvg) and link raysvg; if a raylib target already
exists it is picked up automatically.
./build/demo_static tests/fixtures/shapes.svg
./build/demo_static tests/fixtures/critter.svg --outline blob-outline
./build/demo_channels tests/fixtures/critter.svg
./build/raysvg_dump some.svgshapes.svg exercises primitives, gradients, strokes, clips and var(). critter.svg is a
small layered fixture used by demo_channels to show transforms, opacity, outline, z-index
and reparenting. Both demos take --screenshot out.png to render one frame headlessly and
exit.
Elements svg g path rect (with rx/ry) circle ellipse line polyline
polygon defs clipPath linearGradient stop.
Path data all commands — M L H V C S Q T A Z, absolute and relative. Arcs are converted
to cubics via the endpoint-to-centre parameterisation in SVG 1.1 appendix F.6.
Paint hex (3/4/6/8 digit), rgb()/rgba(), hsl()/hsla(), all CSS named colours,
none, currentColor, url(#gradient), and var(--name) with an optional fallback.
Variables resolve at draw time, so one RaysvgSetVar recolours everything referencing it.
Stroke width, butt/round/square caps, miter/round/bevel joins, miter limit,
dash arrays and dash offset.
Other viewBox, preserveAspectRatio (all alignments, meet and slice), nested
transform chains, transform-origin, opacity/fill-opacity/stroke-opacity, display,
z-index, clip-path with clipPath, and an inline style attribute.
<text>, <use>, <image>, <symbol>, <mask>, <marker>, radial gradients, patterns,
filters, SMIL animation, and fill-rule (paths are filled by containment nesting). There is
no CSS cascade — a <style> block is ignored, and class names are kept for queries only.
Bake stylesheet rules into attributes first (see tools/convert-rig if you start from
layered HTML/CSS rather than a single SVG).
Unsupported constructs warn instead of failing: see RaysvgGetWarnings.
Clipping is done on the CPU. Triangles are clipped against a convex polygon (Sutherland–Hodgman) in the shape's own local space, and the clip polygon is carried down the tree by the inverse of each local transform. A clip therefore belongs to the referencing element's content space, which is what lets an animated child slide under a stationary clip. Concave clip outlines fall back to their bounding box, reported as a warning.
The outline pass replaces feMorphology. RaysvgSetOutline sweeps the subtree twice:
once emitting a fat round-joined stroke of every contour, then again for the artwork. Adjacent
parts merge into one silhouette rather than each being ringed individually. It needs no
offscreen target and no shader.
Gradients are a 256×1 ramp texture plus per-vertex UVs projected onto the gradient axis,
built lazily on first draw. RaysvgNotifyGpuReset drops them after a device loss.
Frame scheduling. Channel writes set a dirty flag that RaysvgNeedsRedraw reports and
drawing clears. Writing a value identical to the current one does not dirty the document,
so a host that re-applies a static pose every frame still goes idle.
The renderer restricts itself to the rlgl subset that behaves identically on stock raylib, rlvk, rlwg and rlmt. These constraints are not stylistic; each one is a bug that was hit:
| Rule | Why |
|---|---|
Never rlMultMatrixf |
Stock raylib and rlwg compose it as mul(new, current); rlvk and rlmt as mul(current, new). Nested transforms would nest backwards on half the backends. Only rlTranslatef/rlRotatef/rlScalef agree, so non-decomposable matrices (shears) are folded into vertices on the CPU instead. |
Never rlSetTexture(0) to unbind |
It clears rlgl's current-texture field without retargeting the open draw call, so a gradient ramp keeps tinting everything drawn after it. Use rlSetTexture(rlGetTextureIdDefault()). |
| Normalise triangle winding | raylib leaves backface culling on. earcut output and stroke join wedges have no predictable orientation, so cached geometry is normalised once at tessellation time and flipped per-triangle when a mirroring transform is active. Toggling rlDisableBackfaceCulling does not work: stock rlgl does not flush the batch, so the state at flush time is whatever the host left. |
| No stencil | rlvk writes stencil state that never reaches a pipeline; rlwg and rlmt stub the entry points entirely. Hence CPU clipping. |
| No custom shaders | Three hand-maintained variants (GLSL, MSL, WGSL) and rlwg cannot compile at runtime. Hence ramp textures for gradients. |
No rlDrawVertexArray* |
No-ops on rlwg. Everything goes through the immediate-mode batch. |
Emission is chunked to 1512 vertices per rlBegin/rlEnd with a rlCheckRenderBatchLimit
ahead of each run, which keeps every backend flush well inside rlmt's 32k-vertex ceiling.
Most users can ignore this. If your artwork already lives as a single .svg, load it directly.
tools/convert-rig is for the case where art is authored as stacked HTML <div> layers (each
wrapping its own <svg>) plus a stylesheet. It flattens that into one document raysvg can
drive:
cd tools/convert-rig
npm install # or symlink an existing node_modules with htmlparser2 + css-tree
node convert.mjs --rig /path/to/your/layers--rig has no default: the output encodes whatever input you point it at. Generated files
land in assets/ by default (gitignored) — produce them locally rather than committing them
here.
The converter writes:
rig.svg— layers as<g>elements in paint order, class-derived presentation baked into inline styles,transform-originpreserved as declared.rig-manifest.json— pivots, visibility rules, palettes, outline configuration, and element ids an animation layer may write to.rig-preview.svg— one resolved state, useful for demos that should not need a JSON reader. Pick a state with--state "<space-separated class list>".
Unrecognised selectors are a hard error, not a silent drop: an authoring change should break the conversion rather than the rendering.
zlib/libpng. Vendored: earcut.hpp (ISC, Mapbox). The elliptical-arc conversion follows the approach in nanosvg (zlib, Mikko Mononen).