A software 3D rasterizer written in C#. The entire pipeline — model transforms, projection, culling, clipping, scanline rasterization, z-buffering and shading — runs on the CPU with no GPU or graphics-API dependency. A WinForms front-end renders live into a bitmap so you can orbit models, switch shading modes, and watch per-frame render statistics.
![]() |
![]() |
|---|---|
| Elephant — 26k triangles across 5 meshes | Parrot — 7k triangles |
- Loads and renders 3D models (Wavefront
.obj, Collada.dae) and procedural primitives in real time. - Rasterizes triangles with a generic scanline filler and a depth (z) buffer.
- Supports several shading modes — wireframe, solid, flat, Gouraud, Phong, textured and full material — selectable at runtime.
- Lights a scene with any number of coloured lights — directional, point with distance falloff, and spot.
- Rasterizes into an HDR linear float target, so highlights brighter than white survive to the post-process stack.
- Surrounds the scene with an environment cube map, drawn as a skybox and reduced to the ambient light the painters use.
- Casts shadows with a shadow-map pass rendered from the light's point of view, and screen-space ambient occlusion for the contact detail a shadow map cannot resolve.
- Shades materials: albedo, tangent-space normal and specular maps, loaded from a model's
.mtl. - Deforms skinned meshes over a scene graph of transforms, played from keyframed animation clips — all imported from Collada.
- Runs a post-process stack over the finished frame — bloom, tone mapping, FXAA, vignette.
- Anti-aliases by supersampling: render at a multiple of the display resolution and average down.
- Provides an interactive arc-ball camera, WASD fly controls, gizmos (world axes, ground grid), and a live stats overlay.
- Ships a graphics debugger — event list, object table and per-pixel history — built on the renderer's own instrumentation.
![]() |
![]() |
|---|---|
| Shadow mapping — one depth pass from the light | Normal mapping — the same two cubes, 12 triangles each; only the left one has a normal map |
| Mode | Painter | Description |
|---|---|---|
| None | — | Geometry only (combine with the wireframe overlay to see edges). |
| Classic | ClassicPainter |
Flat per-triangle base color, no lighting. |
| Flat | FlatPainter |
One Lambert (N·L) light per triangle from its centroid normal. |
| Gouraud | GouraudPainter |
Per-vertex Lambert light interpolated across the triangle. |
| Phong | PhongPainter |
Per-pixel Blinn-Phong from an interpolated world position and normal. |
| Textured | TexturedPainter |
Perspective-correct texturing with Gouraud lighting, bilinear filtering and mip-maps. |
| Material | MaterialPainter |
Per-pixel albedo, normal and specular maps over Blinn-Phong — the full material path. |
A WireFramePainter overlay (Liang–Barsky homogeneous line clipping) can be drawn on top of any mode.
A scene's lights are a list, and every lit painter sums over all of them. Each carries a colour as well as an intensity, so a warm key and a cool fill produce a surface whose lit side and shadowed side differ in hue rather than only in brightness — and the sum is never clamped, because two lights on one surface really do deliver twice the light.
| Light | Falls off | Notes |
|---|---|---|
DirectionalLight |
Never | Parallel rays; the sun. |
PointLight |
With distance, if given a Range |
Windowed inverse-square, reaching exactly zero at the range rather than trailing off forever. Range is infinite by default, which is the no-falloff behaviour the engine had before. |
SpotLight |
With distance and angle | Two cone angles, so the beam's edge ramps instead of stepping — a single angle aliases in the lighting, where no amount of supersampling can reach it. |
The ILight interface is not what a shader talks to. Every light is flattened once per
frame into a ShaderLight — a struct of plain
floats — and the frame's set into a LightSet,
so the per-pixel loop is a branch on a field rather than a virtual call that also forfeits
inlining. The array is reused across frames.
Only the first light casts a shadow: the shadow map is one depth buffer taken from one point of view, so a second shadowed light would need a second pass and a second buffer.
An 8-bit render target cannot hold a value above white. A specular glint five times paper white and one exactly at it are the same pixel by the time anything downstream sees them — which means bloom deciding what is "bright" and tone mapping "compressing the range" are both working on an image whose brights were flattened before they arrived.
Scene.HighDynamicRange rasterizes into a linear float buffer instead
(FrameBuffer.SetHighDynamicRange). A shader
returns a LinearColor — linear light with no
ceiling — and the value is clamped once, at the very end of the frame, after the effects have
had it. It pairs with GammaCorrect, which is the path where the shaders produce light rather
than pre-encoded bytes and so the only one with a range above white to keep.
Two things moved into linear light along with it, because that is where mixing light is defined: alpha blending and fog. Half of a full-intensity channel encodes to about 188, not to 128 — the latter is a good deal darker than half the light.
Scene.Environment is a CubeMap, and it does two
jobs.
SkyRenderer draws it behind the scene. There
is no cube to rasterize: a skybox drawn as geometry is really just a way of getting a
direction interpolated per pixel, and here the direction comes straight from the pixel's
position through the inverse projection — no seams where the cube's own triangles meet, and
nothing that can be clipped by the near plane. It runs between the opaque and transparent
passes, filling only pixels whose depth is still the cleared value: after the opaque fill so
it shades nothing that was covered, and before the transparent one because that blends
without writing depth, so a sky drawn last would paint over the glass rather than behind it.
The same map also becomes the ambient term, as an
AmbientCube — six directional averages instead
of one constant. A flat ambient says a ceiling and a floor in the same room receive the same
light from their surroundings, which is never true: one faces the sky and the other faces the
ground. Averaging each face and blending the three a normal points toward is the cheapest
correction that says otherwise. The per-pixel painters evaluate it with the shading normal,
so a normal map shapes the ambient the same way it shapes the lights.
SkyBox.Gradient generates one procedurally — zenith,
horizon, ground and a sun disc — so a scene can have a sky, and therefore directional ambient,
with no asset to load.
Shadows come from a second, depth-only render of the world from where the light stands
(Pipeline/Shadows/ShadowMapRenderer.cs).
Shading a point then means projecting it with the same matrix and comparing: if something
nearer to the light already occupies that direction, the point is in shadow.
- The light gets an orthographic projection (
OrthographicProjection) sized to a sphere around the whole world, so the map covers the scene from any angle. Point lights are approximated as directional — accurate while the light sits outside the scene. - The pass is the main pipeline with everything that doesn't affect occlusion removed: no colour, no lighting, no varyings. It uses a bounding-box edge-function rasterizer, split into contiguous bands of rows so it parallelizes without locking the depth buffer.
- Phong and Material sample the map per pixel; Flat, Gouraud and Textured fold the visibility into their per-vertex intensity. Ambient light is never shadowed, so a shadowed surface darkens rather than going black.
- Bias is measured in shadow-map texels of depth, not raw normalized depth — one texel of error means the same thing in a 2-unit skull and a 1500-unit elephant, so it does not need retuning per scene or per resolution. A slope term scales it with the light's incidence angle.
- Transparent and hidden meshes are excluded: something you can see through should not block the light, and a mesh dropped from the frame should not leave its shadow behind.
Scene.Shadows (ShadowSettings) controls resolution, bias, 3×3 PCF filtering and shadow strength.
Every mesh carries a Material: a diffuse
colour, an albedo map, a tangent-space normal map, a specular mask, and the two numbers
that shape its highlight. Mesh.Texture is the albedo map under its older name, so nothing
that predates materials had to change.
Normal mapping adds surface detail finer than a vertex without adding a single triangle. It
needs a per-vertex UV frame, which TangentBuilder
derives by solving each triangle's edges against its UV deltas and accumulating the result
per vertex — including the ±1 handedness that mirrored UV islands flip. Frames are built
in the painter's Prepare, before the parallel paint phase, exactly as mip chains are.
The OBJ importer reads Kd, Ks, Ns, d/Tr, map_Kd, map_Ks and the normal map
under all of its spellings (map_Bump, bump, norm).
NormalMapBuilder converts a height map
into a normal map, since map_Bump was specified as one and is used as the other.
A mesh with nothing but its own position can only ever be placed absolutely. There is no way to say the hand goes wherever the arm puts it — and that sentence is most of what a model that moves consists of.
SceneNode is one transform in a hierarchy:
a local translation, rotation and scale, a parent, and the world matrix that composing the
chain produces. IMesh.Parent hangs a mesh off one, and the mesh's own transform becomes an
offset from the node rather than from the origin. Nothing that predates the graph changed —
a mesh with no parent composes exactly the matrix it always did.
Two decisions worth naming:
- World matrices are cached, not walked on every read. A skinned mesh reads its joints' world matrices once per vertex influence; re-composing the chain at each read would make a deep skeleton quadratic in its own depth.
- A node's rotation is a quaternion, where
Meshstill carries Euler angles. Animation has to interpolate between two rotations, and Euler angles interpolate through gimbal lock.
Nodes also carry a SceneNodeKind. A
scene file's node tree is not all rig: exported alongside the bones are the nodes holding the
artist's lights and cameras, which sit metres away and dwarf the model. Only the skeleton view
reads the kind, and it is what makes that view legible on a real file.
A tube rigged to seven joints, mid-bend. The wireframe is the point: the rings crowd on the inside of each curve and spread on the outside, which is a mesh being deformed rather than a rigid one being rotated.
SkinnedMesh implements linear blend
skinning. Each vertex is transformed by every joint that claims it and the results mixed by
weight — equivalently, and this is how it is computed, the joint matrices are mixed first and
the vertex transformed once.
The deformed positions are written back into the arrays Mesh
already exposes, so the renderer needs no knowledge of skinning at all: it transforms
Mesh.Vertices exactly as it always has, and they simply happen to have moved since the last
frame. The bind pose is kept privately, because deforming the deformed output would compound
the pose frame after frame.
- A
Skeletonholds the joint nodes and their inverse bind matrices. A joint's inverse bind undoes the transform it had when the mesh was modelled; its current world matrix puts the vertex back down wherever the joint has moved to. The product is the only thing skinning needs per joint, so it is built once per pose rather than once per vertex. SkinWeightsstores a fixed four influences per vertex, flat rather than as an array of small arrays. Four is a budget, not a format limit — riggers paint six or eight, and the fifth is worth a fraction of a percent — so the builder keeps the four heaviest and renormalizes.- Normals are deformed too, by the same blended matrix and then renormalized. Strictly a normal wants the inverse transpose, which differs once a joint scales non-uniformly; joints rotate and translate, and renormalizing covers the rest. Tangents follow the same path when a skinned mesh has them, so normal mapping survives a pose.
- The bounding sphere is remeasured with every pose. The renderer culls whole meshes against it, and a raised arm reaches outside the sphere the bind pose fitted in.
One deliberate leniency: a skin covering fewer vertices than its mesh leaves the rest at bind pose instead of throwing. Malformed weight tables are common in exported files, and a model that renders with a stiff patch beats one that will not load.
An AnimationClip is a set of per-node
curves — translation and scale as Vector3Tracks, rotation as a QuaternionTrack that slerps
along the shorter of the two arcs between neighbours. Clips are pure data and hold no playhead,
so two things can play one clip at different times; AnimationPlayer
owns the time, the speed and the looping.
Channels address nodes by name, which keeps a clip independent of any one skeleton. Names are resolved once, at construction, into an array parallel to the channel list — the parrot would otherwise spend thousands of string comparisons per frame to move a wing.
Outside a clip's own span, values are held rather than extrapolated. A clip says nothing about what happens before its first key, and guessing produces motion no animator authored.
Files bake a joint's whole transform into one float4x4 curve far more often than they key
the components separately, so a matrix track is decomposed into translation, rotation and
scale once, at load. Blending the matrices themselves component by component shears a
rotating joint, because the halfway point between two rotation matrices is not a rotation
matrix.
IWorld.Update(deltaSeconds) runs the whole chain: play the clips, refresh the hierarchy,
re-deform anything skinned. The renderer never calls it — rendering a frame twice, which the
graphics debugger does every time a probed pixel is re-recorded, must not advance time.
MeshFactory.ImportColladaScene returns a ColladaScene:
the meshes, the visual scene's node tree, the skin controllers bound to it, and the animation
channels that pose it. HackyImportCollada is untouched and still returns bare meshes, which
is all a static model needs.
Collada writes matrices for the column-vector convention — a point is transformed as
M·v, and a node's translation sits in the fourth column. This engine composes row-vector
matrices, v·M, with translation in the fourth row. The two are transposes of each other, so
every matrix is transposed on the way in and nothing downstream knows the file's convention.
The bind shape matrix is folded into the bind pose at load, so it costs nothing per frame.
A skeleton is invisible in a rendered image by construction: it is the thing that moves the
vertices, never a thing that gets drawn. Which makes a rig that is subtly wrong indistinguishable
from a mesh that is subtly wrong. Settings.ShowSkeleton draws the hierarchy as bones — a line
to each node's parent, and a three-axis tick at each joint so a node with no children is still
visible.
Three bundled demos cover the two halves of the problem and the case where both are present:
| Demo | What it shows |
|---|---|
| Bone chain (skinned) | Geometry, rig and clip all generated — the smallest thing that exercises the whole skinning path, with a correct answer that is obvious by eye. |
| Juliet (skinned) | A real 55,000-vertex skin from a real file: 205 joints and weights painted by whoever rigged her. The file carries no animation, so the sway is authored against the joint names her rig actually uses — with every key composed on top of the joint's rest orientation, since a clip written against someone else's rig must not discard the pose it was modelled in. |
| Parrot rig (animated) | A twelve-second clip over a sixty-node hierarchy, and no skin binding the mesh to any of it — so a cube on each joint makes the hierarchy the model. Move a wing joint and the four cubes below it go with it. |
PostProcessStack runs
full-screen effects over the finished render target. It owns the conversion at both ends, so
effects only ever see linear float RGB — the space blurring and adding light are actually
defined in. Against an HDR surface the image arrives already linear and unbounded and no
decode happens at all; against an 8-bit one the stack decodes sRGB on the way in, and nothing
downstream can recover highlights the rasterizer already clipped.
| Effect | What it does |
|---|---|
| SSAO | Reconstructs a position and a normal per pixel from the depth buffer, samples a hemisphere around each, and darkens by the fraction of samples something else is in front of. |
| Bloom | Bright-passes and blurs at a quarter resolution, then adds the result back — a wide blur is what sells the effect, and it costs a sixteenth of the samples downsampled. |
| Tone map | Exposure plus a Reinhard or ACES filmic curve, so values above white roll off instead of flattening into one clipped patch. |
| FXAA | Detects luminance edges, works out which way each runs, and blurs across it — never along it. Fixed screen-sized cost rather than a multiple of the whole render. |
| Vignette | Darkens the frame toward its corners, normalized so it behaves the same at any aspect ratio. |
SsaoEffect finds the creases and
contact points a shadow map at any sane resolution cannot resolve, from nothing but the depth
buffer the frame already has. The depth buffer is a partial model of the scene: one point in
space per pixel, and — by differencing its neighbours — the surface's orientation there. A
point is occluded to the extent that other points sit above its own tangent plane nearby.
Effects that want this declare NeedsDepth, and the stack then reads the z-buffer back as
view-space distance (FrameBuffer.ReadViewDepth)
rather than as the quantized device depth the depth test uses — a screen-space effect's radius
is measured in world units, so it needs the distance, not something merely monotonic in it.
The read costs a full-screen pass, so it only happens when something enabled asked for it, and
it is only possible under a perspective projection.
Two honest limitations. It only knows what is on screen, so geometry outside the frame or
hidden behind something nearer occludes nothing — inherent to the technique. And it multiplies
the finished image, which darkens direct light along with ambient; separating the two in a
forward renderer means carrying the ambient in a buffer of its own through the whole frame.
Radius is a world-space distance and is the one number that must be scaled to the scene.
model ──worldMatrix──▶ world ──viewMatrix──▶ view ──projectionMatrix──▶ clip ──/w──▶ NDC ──▶ screen
Per frame, the Renderer (Pipeline/Renderer.cs):
- Clears the color and z-buffers.
- Renders the shadow map from the light, if the scene casts shadows — before any painter prepares, since every shade that follows reads it.
- Transforms each mesh's vertices into view space (pooled
VertexBufferper mesh). - Rejects triangles behind the far plane, back-facing triangles (optional culling), and triangles outside the view frustum.
- Projects survivors into clip space, maps to screen space, and bins them into the screen tiles they cover.
- Fills the tiles in parallel through the active painter.
- Draws the sky into whatever pixels the opaque pass left untouched.
- Blends the transparent triangles over the result, farthest first.
- Draws optional gizmos (XZ grid, world axes, skeleton).
- Runs the post-process stack over the finished image, and encodes it for presentation.
The rasterizer (Rasterization/ScanlineRasterizer.cs) sorts a triangle's vertices by Y, splits it at the middle vertex, and walks two half-triangles, interpolating depth plus an arbitrary varying payload. Painters only supply a varying type and a shader — both are struct generics, so the JIT devirtualizes and inlines the per-pixel shade call with no allocation on the hot path.
The fill phase is split by screen tile, not by triangle: TileBinner
sorts each frame's triangles into the 32×32-pixel tiles their bounding boxes touch, and one
worker owns each tile (ScreenTile). Because
tiles never overlap, the z-buffer needs no locking — and because a triangle only reaches the
tiles it covers, its per-triangle setup is paid once per tile instead of once per core.
Three things fall out of owning a rectangle of pixels:
- Coarse depth rejection. Before drawing a triangle into a tile, its nearest depth is
compared against the farthest depth currently stored anywhere in that tile. If it is behind,
the triangle is dropped whole — no rows walked, no pixels tested. The bound is re-read every
few triangles, and a scan that buys no rejection doubles the interval to the next one, so a
scene with no depth complexity stops paying for it.
Settings.HierarchicalZturns it off. - Vectorized depth tests. Runs of
Vector<int>.Countpixels are depth-tested at once, and a run entirely behind the z-buffer is skipped without interpolating or shading any of it. Depth is evaluated as an affine function of x rather than a running sum, so the vector test and the scalar loop agree exactly. - Contiguous memory. A tile walks the framebuffer the way it is laid out, rather than every n-th row.
Measured at 1280×720 on eight hardware threads, the three together render a dense model about 1.5–1.8× faster than the row-interleaved fill they replace, and a scene with heavy overdraw about 2× faster.
SuperSampler resolves a render target drawn
at an integer multiple of the display resolution back down to it. It is the one kind of
anti-aliasing that asks nothing of the rasterizer — the whole pipeline just runs larger — so
unlike FXAA it smooths specular glints and texture shimmer as well as silhouettes. Colours are
averaged in linear light, and alpha along with them, which leaves the edge pixels of a shape
over the cleared background correctly premultiplied for presentation. A 2× frame fills four
times the pixels, which is exactly what it costs.
The WinForms app (SoftEngine.WinForms) renders the scene into a 32-bpp bitmap that is blitted to a Panel3D.
| Control | Action |
|---|---|
| Left-drag | Orbit the arc-ball camera |
| Right-drag | Pan; left+right-drag dollies |
| Mouse wheel | Move the camera in/out — the status bar's zoom percentage follows it (100% is the framing a world loads with) |
| W / A / S / D | Fly the camera forward / left / back / right (Q/E for down/up). Hold Shift to move faster, Ctrl for fine steps; the step scales with the camera's distance, so it works on a 2-unit skull and a 1500-unit elephant alike |
| Left-click the viewport | Probe that pixel — its full write history appears in the Pixel History panel (Esc clears it) |
| Load model… | Pick a bundled world (skull, parrot, elephant, teapot, cubes, spheres, towns, shadows, normal mapping, the three animated ones…) or open an OBJ/Collada file from disk |
| Shading radios | Switch between None / Classic / Flat / Gouraud / Phong / Textured / Material |
| Display checkboxes | Toggle wireframe triangles, back-face culling, XZ grid, world axes, skeleton, animation, fog, shadows, sky, gamma-correct light, HDR target, texture filtering, 2× supersampling |
| Post-processing checkboxes | Toggle ambient occlusion, bloom, tone mapping, FXAA and vignette independently |
A stats overlay reports triangle counts (total / back-facing / out-of-view / behind), pixel counts (drawn / z-rejected), and calculation vs. paint timing per frame.
The front-end doubles as a small graphics debugger, modelled on Rasterizr Studio. Because the whole pipeline runs on the CPU, the panels show what the renderer actually did rather than what a driver reported.
| Panel | Shows |
|---|---|
| Graphics Event List | Every step of the frame in pipeline order — viewport and depth-range setup, buffer clears, the shadow-map pass, the view and projection matrices, then per mesh: vertex transform, cull results and the draw call, then the post-process pass and the present. |
| Graphics Object Table | Every object the frame touched — render target, depth buffer, camera, projection, painter, shadow map, post-process stack, lights, meshes and textures — with its size, vertex/triangle counts and dimensions. Meshes carry an active checkbox that drops them from the frame. |
| Pixel History | For the selected pixel: the clear, then each triangle that tried to write it — including the ones the depth test rejected — with the input-assembler and transformed vertex data, the depth comparison, and the previous → resulting colour, ending with the post-process pass's before → after. |
Identifiers are shared: obj:7 in the event list is obj:7 in the object table, and clicking an entry in the pixel history selects both.
Recording is driven from RenderDiagnostics on the renderer (Diagnostics/):
- Events are stored as a
readonly record structwith a numeric payload in a reused buffer, and formatted only for the rows the list actually draws — a busy scene emits thousands of events per frame and capturing them allocates nothing. - The pixel probe is a single int compare inside
FrameBuffer.PutPixel, off (-1) unless a pixel is selected. The "what is drawing" context is thread-static: each paint worker owns a disjoint set of screen rows, so the one worker that owns the probed pixel is also the one that tags its writes, and they stay in draw order. - Triangle vertices are snapshotted only when a write actually lands on the probed pixel, never per triangle.
Both can be switched off from the View menu, along with each panel.
src/
├── SoftEngine.Core/ # engine, no UI dependency (net10.0 class library)
│ ├── Animation/ # keyframe tracks, node channels, clips, playback
│ ├── Buffers/ # FrameBuffer (color + z-buffer + pixel probe), pooled Vertex/World buffers
│ ├── Diagnostics/ # render stats, graphics event log, pixel history
│ ├── Geometry/ # IMesh/Mesh, Material, Triangle, tangents, primitives, OBJ/Collada importers
│ │ └── Skinning/ # skeleton, skin weights, linear blend skinning, generated bone chain
│ ├── Pipeline/ # Renderer, settings, homogeneous clipping, sky pass
│ │ ├── PostProcess/ # effect stack: SSAO, bloom, tone map, FXAA, vignette
│ │ └── Shadows/ # depth-only shadow-map pass
│ ├── Rasterization/ # scanline filler, painters, shaders, varyings, texture sampling
│ ├── Scenes/ # world, camera, projections, lights, fog and shadow settings
│ │ └── Graph/ # SceneNode transform hierarchy
│ └── Shading/ # linear colour, light sets, ambient cube, sRGB conversion, shadow map
└── SoftEngine.WinForms/ # interactive front-end (net10.0-windows)
├── Debugging/ # event list, object table and pixel history panels
└── Dialogs/ # model picker
- .NET 10 SDK
- Windows (the interactive app uses WinForms;
SoftEngine.Coreitself is platform-neutral).
# build everything
dotnet build SoftEngine.slnx
# run the interactive app
dotnet run --project src/SoftEngine.WinFormsThe renderer avoids managed-heap traffic on the pixel hot path:
ColorRGBis areadonly struct— shaders can produce a color per pixel without allocating.- Struct-based varyings and shaders let the JIT inline the shade call instead of dispatching through an interface.
ArrayPool-backed vertex buffers are rented per frame rather than allocated.- Tile bins are flat arrays filled by a counting sort, reused across frames, so binning tens of thousands of triangles allocates nothing either.
- The frame's lights are flattened into structs once, so the per-pixel loop over them branches on a field instead of dispatching through
ILight.
One cost was accepted rather than avoided: shaders now return LinearColor, so a shader with
no HDR range to give converts a ColorRGB in and the framebuffer converts it back out —
six lookup-table reads per pixel that the old packed-byte path did not pay. Both tables are
small enough to stay in L1, and the alternative was two parallel shading paths.
Work is measured rather than assumed: the numbers above come from a headless harness that renders fixed scenes — dense models, heavy overdraw, a few huge triangles — and reports the median frame time.
- Cascaded shadow maps, so a large scene doesn't spend one uniform resolution on all of it.
- Physically-based shading (metallic–roughness), which the environment map is already most of the way to supporting.
- A glTF 2.0 importer, which would bring PBR materials and skinning in one well-specified format.
- Replace
Mesh'sRotation3D(Euler angles) with the quaternion rotationSceneNodealready uses. - Animation blending — crossfading two clips, and layering one over another — which the player is one weight away from.
- Frame capture history, so the debugger can step back through earlier frames.
- Buffer visualizers in the debugger: depth, normals, overdraw, mip level, the shadow map itself.
Inspired by David Rousset's tutorial series Learning how to write a 3D soft engine from scratch in C#, TypeScript or JavaScript, which this project started from before growing its own pipeline, rasterizer, and shading system.
MIT © Hilthon







