Start of 3D support.

This commit is contained in:
Scott Duensing 2026-09-05 18:38:46 -05:00
parent 3f4b7aa405
commit 0060116835
18 changed files with 16996 additions and 8 deletions

View file

@ -18,6 +18,18 @@ API Changes
database), and lfs.mkdir and lfs.rmdir act on its data overlay, so
frameworks that list or create their own directories work packed.
- 3D scenes. A game can draw a 3D scene between the disc video and the
overlay: primitive meshes and script-built geometry, materials with
colour, textures from sprites, the disc or a loaded video, metallic and
roughness, up to eight lights, any node as the camera, and glTF 2.0
models (.glb, self-contained) with node animation and skinning, placed
any number of times. Everything is a node in one tree. New calls:
scene*, node*, mesh*, material*, light*, camera*, model*, animation*,
and the LIGHT_* constants; see the 3D Scenes chapter of the manual.
Needs a GPU with Vulkan, Direct3D 12 or Metal (Raspberry Pi 4 or
later); 2D games run as before without one. The renderer now runs on
SDL's GPU device where there is one.
- A game can be one file: singe --pack DIRECTORY GAME.game writes the game's
scripts, art, sounds, fonts, and video into an SQLite database, and
singe GAME.game (or the menu, which lists every .game file beside the game

View file

@ -273,6 +273,12 @@ set(SINGE_SOURCE
src/main.h
src/pack.c
src/pack.h
src/model.c
src/model.h
src/math3d.c
src/math3d.h
src/scene.c
src/scene.h
src/singe.c
src/singe.h
src/stddclmr.h

View file

@ -5,6 +5,7 @@ these tools, Singe would not exist.
arg_parser BSD-2-Clause http://savannah.nongnu.org/projects/arg-parser
binaryheap.lua MIT http://tieske.github.io/binaryheap.lua
cgltf MIT https://github.com/jkuhlmann/cgltf
copas MIT https://lunarmodules.github.io/copas
ffmpeg LGPL-2.1 https://ffmpeg.org
freetype FTL https://freetype.org

File diff suppressed because it is too large Load diff

View file

@ -298,6 +298,7 @@ static void _launcher(const char *exeName, ConfigT *conf) {
float bestRatio = HUGE_VALF;
SDL_Window *window = NULL;
SDL_Renderer *renderer = NULL;
SDL_GPUDevice *device = NULL;
SDL_Surface *icon = NULL;
MIX_Mixer *mixer = NULL;
const SDL_DisplayMode *mode = NULL;
@ -402,12 +403,27 @@ static void _launcher(const char *exeName, ConfigT *conf) {
SDL_SyncWindow(window);
}
// Create a renderer. SDL prefers accelerated drivers but can fall back to software.
// Create a renderer. On a GPU device when the platform has one, so the 3D scene can share it;
// otherwise SDL's ordinary renderer, which prefers accelerated drivers but can fall back to software.
_mainTrace(conf, "Creating renderer");
device = SDL_CreateGPUDevice(SDL_GPU_SHADERFORMAT_SPIRV | SDL_GPU_SHADERFORMAT_DXIL | SDL_GPU_SHADERFORMAT_MSL, false, NULL);
if (device == NULL) {
_mainTrace(conf, "No GPU device (%s); 3D is unavailable", SDL_GetError());
} else {
renderer = SDL_CreateGPURenderer(device, window);
if (renderer == NULL) {
_mainTrace(conf, "GPU renderer failed (%s); 3D is unavailable", SDL_GetError());
SDL_DestroyGPUDevice(device);
device = NULL;
}
}
if (renderer == NULL) {
renderer = SDL_CreateRenderer(window, NULL);
if (renderer == NULL) {
utilDie("%s", SDL_GetError());
}
}
_mainTrace(conf, "Renderer: %s", SDL_GetRendererName(renderer));
// Clear screen with black
SDL_SetRenderDrawColor(renderer, 0, 0, 0, SDL_ALPHA_OPAQUE);
@ -435,7 +451,7 @@ static void _launcher(const char *exeName, ConfigT *conf) {
// Run Singe!
_mainTrace(conf, "Starting Singe");
singe(window, renderer, conf);
singe(window, renderer, device, conf);
// Shutdown - framefiles own video handles, so they go first.
_mainTrace(conf, "Shutting down laserdisc framefile handler");
@ -446,6 +462,9 @@ static void _launcher(const char *exeName, ConfigT *conf) {
MIX_DestroyMixer(mixer);
_mainTrace(conf, "Destroying renderer");
SDL_DestroyRenderer(renderer);
if (device != NULL) {
SDL_DestroyGPUDevice(device);
}
_mainTrace(conf, "Destroying window");
SDL_DestroyWindow(window);
_mainTrace(conf, "Re-enabling screen saver");

499
src/math3d.c Normal file
View file

@ -0,0 +1,499 @@
/*
*
* Singe 3
* Copyright (C) 2006-2026 Scott Duensing <scott@kangaroopunch.com>
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
* as published by the Free Software Foundation; either version 3
* of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
* 02110-1301, USA.
*
*/
// Vector, quaternion and matrix arithmetic for the 3D scene. Small on purpose: only what a scene
// graph, a camera and glTF animation need.
#include <math.h>
#include <string.h>
#include "math3d.h"
#define PI 3.14159265358979323846f
#define DEGREES_TO_RADIANS(d) ((d) * (PI / 180.0f))
#define RADIANS_TO_DEGREES(r) ((r) * (180.0f / PI))
#define EPSILON 1e-6f
// Translation * rotation * scale, the glTF node transform.
Mat4T mat4Compose(Vec3T translation, QuatT rotation, Vec3T scale) {
Mat4T out;
float xx = rotation.x * rotation.x;
float yy = rotation.y * rotation.y;
float zz = rotation.z * rotation.z;
float xy = rotation.x * rotation.y;
float xz = rotation.x * rotation.z;
float yz = rotation.y * rotation.z;
float wx = rotation.w * rotation.x;
float wy = rotation.w * rotation.y;
float wz = rotation.w * rotation.z;
out.m[0] = (1.0f - 2.0f * (yy + zz)) * scale.x;
out.m[1] = (2.0f * (xy + wz)) * scale.x;
out.m[2] = (2.0f * (xz - wy)) * scale.x;
out.m[3] = 0.0f;
out.m[4] = (2.0f * (xy - wz)) * scale.y;
out.m[5] = (1.0f - 2.0f * (xx + zz)) * scale.y;
out.m[6] = (2.0f * (yz + wx)) * scale.y;
out.m[7] = 0.0f;
out.m[8] = (2.0f * (xz + wy)) * scale.z;
out.m[9] = (2.0f * (yz - wx)) * scale.z;
out.m[10] = (1.0f - 2.0f * (xx + yy)) * scale.z;
out.m[11] = 0.0f;
out.m[12] = translation.x;
out.m[13] = translation.y;
out.m[14] = translation.z;
out.m[15] = 1.0f;
return out;
}
// Splits a TRS matrix back into its parts (glTF nodes may carry a matrix instead of TRS).
void mat4Decompose(Mat4T a, Vec3T *translation, QuatT *rotation, Vec3T *scale) {
Vec3T x = vec3(a.m[0], a.m[1], a.m[2]);
Vec3T y = vec3(a.m[4], a.m[5], a.m[6]);
Vec3T z = vec3(a.m[8], a.m[9], a.m[10]);
Mat4T r = mat4Identity();
*translation = vec3(a.m[12], a.m[13], a.m[14]);
*scale = vec3(vec3Length(x), vec3Length(y), vec3Length(z));
// A negative determinant means one axis is mirrored; put the flip on X.
if (vec3Dot(vec3Cross(x, y), z) < 0.0f) {
scale->x = -scale->x;
}
x = vec3Scale(x, (scale->x != 0.0f) ? 1.0f / scale->x : 0.0f);
y = vec3Scale(y, (scale->y != 0.0f) ? 1.0f / scale->y : 0.0f);
z = vec3Scale(z, (scale->z != 0.0f) ? 1.0f / scale->z : 0.0f);
r.m[0] = x.x;
r.m[1] = x.y;
r.m[2] = x.z;
r.m[4] = y.x;
r.m[5] = y.y;
r.m[6] = y.z;
r.m[8] = z.x;
r.m[9] = z.y;
r.m[10] = z.z;
*rotation = quatFromMat4(r);
}
Mat4T mat4Identity(void) {
Mat4T out;
memset(&out, 0, sizeof(out));
out.m[0] = 1.0f;
out.m[5] = 1.0f;
out.m[10] = 1.0f;
out.m[15] = 1.0f;
return out;
}
// General 4x4 inverse by cofactors. Returns false for a singular matrix (out untouched).
bool mat4Invert(Mat4T a, Mat4T *out) {
float inv[16];
float det;
int32_t x;
const float *m = a.m;
inv[0] = m[5] * m[10] * m[15] - m[5] * m[11] * m[14] - m[9] * m[6] * m[15] + m[9] * m[7] * m[14] + m[13] * m[6] * m[11] - m[13] * m[7] * m[10];
inv[4] = -m[4] * m[10] * m[15] + m[4] * m[11] * m[14] + m[8] * m[6] * m[15] - m[8] * m[7] * m[14] - m[12] * m[6] * m[11] + m[12] * m[7] * m[10];
inv[8] = m[4] * m[9] * m[15] - m[4] * m[11] * m[13] - m[8] * m[5] * m[15] + m[8] * m[7] * m[13] + m[12] * m[5] * m[11] - m[12] * m[7] * m[9];
inv[12] = -m[4] * m[9] * m[14] + m[4] * m[10] * m[13] + m[8] * m[5] * m[14] - m[8] * m[6] * m[13] - m[12] * m[5] * m[10] + m[12] * m[6] * m[9];
inv[1] = -m[1] * m[10] * m[15] + m[1] * m[11] * m[14] + m[9] * m[2] * m[15] - m[9] * m[3] * m[14] - m[13] * m[2] * m[11] + m[13] * m[3] * m[10];
inv[5] = m[0] * m[10] * m[15] - m[0] * m[11] * m[14] - m[8] * m[2] * m[15] + m[8] * m[3] * m[14] + m[12] * m[2] * m[11] - m[12] * m[3] * m[10];
inv[9] = -m[0] * m[9] * m[15] + m[0] * m[11] * m[13] + m[8] * m[1] * m[15] - m[8] * m[3] * m[13] - m[12] * m[1] * m[11] + m[12] * m[3] * m[9];
inv[13] = m[0] * m[9] * m[14] - m[0] * m[10] * m[13] - m[8] * m[1] * m[14] + m[8] * m[2] * m[13] + m[12] * m[1] * m[10] - m[12] * m[2] * m[9];
inv[2] = m[1] * m[6] * m[15] - m[1] * m[7] * m[14] - m[5] * m[2] * m[15] + m[5] * m[3] * m[14] + m[13] * m[2] * m[7] - m[13] * m[3] * m[6];
inv[6] = -m[0] * m[6] * m[15] + m[0] * m[7] * m[14] + m[4] * m[2] * m[15] - m[4] * m[3] * m[14] - m[12] * m[2] * m[7] + m[12] * m[3] * m[6];
inv[10] = m[0] * m[5] * m[15] - m[0] * m[7] * m[13] - m[4] * m[1] * m[15] + m[4] * m[3] * m[13] + m[12] * m[1] * m[7] - m[12] * m[3] * m[5];
inv[14] = -m[0] * m[5] * m[14] + m[0] * m[6] * m[13] + m[4] * m[1] * m[14] - m[4] * m[2] * m[13] - m[12] * m[1] * m[6] + m[12] * m[2] * m[5];
inv[3] = -m[1] * m[6] * m[11] + m[1] * m[7] * m[10] + m[5] * m[2] * m[11] - m[5] * m[3] * m[10] - m[9] * m[2] * m[7] + m[9] * m[3] * m[6];
inv[7] = m[0] * m[6] * m[11] - m[0] * m[7] * m[10] - m[4] * m[2] * m[11] + m[4] * m[3] * m[10] + m[8] * m[2] * m[7] - m[8] * m[3] * m[6];
inv[11] = -m[0] * m[5] * m[11] + m[0] * m[7] * m[9] + m[4] * m[1] * m[11] - m[4] * m[3] * m[9] - m[8] * m[1] * m[7] + m[8] * m[3] * m[5];
inv[15] = m[0] * m[5] * m[10] - m[0] * m[6] * m[9] - m[4] * m[1] * m[10] + m[4] * m[2] * m[9] + m[8] * m[1] * m[6] - m[8] * m[2] * m[5];
det = m[0] * inv[0] + m[1] * inv[4] + m[2] * inv[8] + m[3] * inv[12];
if (fabsf(det) < EPSILON) {
return false;
}
det = 1.0f / det;
for (x = 0; x < 16; x++) {
out->m[x] = inv[x] * det;
}
return true;
}
// A view matrix: the camera at eye looking at target.
Mat4T mat4LookAt(Vec3T eye, Vec3T target, Vec3T up) {
Mat4T out;
Vec3T f = vec3Normalize(vec3Subtract(target, eye));
Vec3T s = vec3Normalize(vec3Cross(f, up));
Vec3T u = vec3Cross(s, f);
out.m[0] = s.x;
out.m[1] = u.x;
out.m[2] = -f.x;
out.m[3] = 0.0f;
out.m[4] = s.y;
out.m[5] = u.y;
out.m[6] = -f.y;
out.m[7] = 0.0f;
out.m[8] = s.z;
out.m[9] = u.z;
out.m[10] = -f.z;
out.m[11] = 0.0f;
out.m[12] = -vec3Dot(s, eye);
out.m[13] = -vec3Dot(u, eye);
out.m[14] = vec3Dot(f, eye);
out.m[15] = 1.0f;
return out;
}
// a * b: applies b first, then a.
Mat4T mat4Multiply(Mat4T a, Mat4T b) {
Mat4T out;
int32_t column;
int32_t row;
int32_t k;
float sum;
for (column = 0; column < 4; column++) {
for (row = 0; row < 4; row++) {
sum = 0.0f;
for (k = 0; k < 4; k++) {
sum += a.m[k * 4 + row] * b.m[column * 4 + k];
}
out.m[column * 4 + row] = sum;
}
}
return out;
}
// Depth maps to 0..1 (what SDL_GPU expects on every backend).
Mat4T mat4Orthographic(float width, float height, float near, float far) {
Mat4T out;
memset(&out, 0, sizeof(out));
out.m[0] = 2.0f / width;
out.m[5] = 2.0f / height;
out.m[10] = -1.0f / (far - near);
out.m[14] = -near / (far - near);
out.m[15] = 1.0f;
return out;
}
Mat4T mat4Perspective(float fovDegrees, float aspect, float near, float far) {
Mat4T out;
float f = 1.0f / tanf(DEGREES_TO_RADIANS(fovDegrees) / 2.0f);
memset(&out, 0, sizeof(out));
out.m[0] = f / aspect;
out.m[5] = f;
out.m[10] = far / (near - far);
out.m[11] = -1.0f;
out.m[14] = (near * far) / (near - far);
return out;
}
Vec3T mat4TransformPoint(Mat4T a, Vec3T p) {
Vec3T out;
float w = a.m[3] * p.x + a.m[7] * p.y + a.m[11] * p.z + a.m[15];
out.x = a.m[0] * p.x + a.m[4] * p.y + a.m[8] * p.z + a.m[12];
out.y = a.m[1] * p.x + a.m[5] * p.y + a.m[9] * p.z + a.m[13];
out.z = a.m[2] * p.x + a.m[6] * p.y + a.m[10] * p.z + a.m[14];
if (fabsf(w) > EPSILON) {
out.x /= w;
out.y /= w;
out.z /= w;
}
return out;
}
// Directions ignore translation.
Vec3T mat4TransformVector(Mat4T a, Vec3T v) {
Vec3T out;
out.x = a.m[0] * v.x + a.m[4] * v.y + a.m[8] * v.z;
out.y = a.m[1] * v.x + a.m[5] * v.y + a.m[9] * v.z;
out.z = a.m[2] * v.x + a.m[6] * v.y + a.m[10] * v.z;
return out;
}
Mat4T mat4Transpose(Mat4T a) {
Mat4T out;
int32_t column;
int32_t row;
for (column = 0; column < 4; column++) {
for (row = 0; row < 4; row++) {
out.m[column * 4 + row] = a.m[row * 4 + column];
}
}
return out;
}
QuatT quatFromAxisAngle(Vec3T axis, float degrees) {
QuatT out;
float half = DEGREES_TO_RADIANS(degrees) / 2.0f;
float s = sinf(half);
Vec3T n = vec3Normalize(axis);
out.x = n.x * s;
out.y = n.y * s;
out.z = n.z * s;
out.w = cosf(half);
return out;
}
// Intrinsic rotations applied in the order Y (yaw), X (pitch), Z (roll), matching what a script
// means by "turn, then tilt, then bank".
QuatT quatFromEuler(float xDegrees, float yDegrees, float zDegrees) {
QuatT qx = quatFromAxisAngle(vec3(1.0f, 0.0f, 0.0f), xDegrees);
QuatT qy = quatFromAxisAngle(vec3(0.0f, 1.0f, 0.0f), yDegrees);
QuatT qz = quatFromAxisAngle(vec3(0.0f, 0.0f, 1.0f), zDegrees);
return quatMultiply(quatMultiply(qy, qx), qz);
}
// The rotation part of a matrix, assuming no scale.
QuatT quatFromMat4(Mat4T a) {
QuatT out;
float trace = a.m[0] + a.m[5] + a.m[10];
float s;
if (trace > 0.0f) {
s = sqrtf(trace + 1.0f) * 2.0f;
out.w = 0.25f * s;
out.x = (a.m[6] - a.m[9]) / s;
out.y = (a.m[8] - a.m[2]) / s;
out.z = (a.m[1] - a.m[4]) / s;
} else if ((a.m[0] > a.m[5]) && (a.m[0] > a.m[10])) {
s = sqrtf(1.0f + a.m[0] - a.m[5] - a.m[10]) * 2.0f;
out.w = (a.m[6] - a.m[9]) / s;
out.x = 0.25f * s;
out.y = (a.m[4] + a.m[1]) / s;
out.z = (a.m[8] + a.m[2]) / s;
} else if (a.m[5] > a.m[10]) {
s = sqrtf(1.0f + a.m[5] - a.m[0] - a.m[10]) * 2.0f;
out.w = (a.m[8] - a.m[2]) / s;
out.x = (a.m[4] + a.m[1]) / s;
out.y = 0.25f * s;
out.z = (a.m[9] + a.m[6]) / s;
} else {
s = sqrtf(1.0f + a.m[10] - a.m[0] - a.m[5]) * 2.0f;
out.w = (a.m[1] - a.m[4]) / s;
out.x = (a.m[8] + a.m[2]) / s;
out.y = (a.m[9] + a.m[6]) / s;
out.z = 0.25f * s;
}
return quatNormalize(out);
}
QuatT quatIdentity(void) {
QuatT out = { 0.0f, 0.0f, 0.0f, 1.0f };
return out;
}
// The rotation that points -Z along forward with +Y near up.
QuatT quatLookRotation(Vec3T forward, Vec3T up) {
Mat4T m;
Vec3T f = vec3Normalize(forward);
Vec3T s;
Vec3T u;
if (vec3Length(vec3Cross(f, up)) < EPSILON) {
// Looking straight along up: pick any perpendicular.
up = (fabsf(f.y) < 0.9f) ? vec3(0.0f, 1.0f, 0.0f) : vec3(0.0f, 0.0f, 1.0f);
}
s = vec3Normalize(vec3Cross(f, up));
u = vec3Cross(s, f);
m = mat4Identity();
m.m[0] = s.x;
m.m[1] = s.y;
m.m[2] = s.z;
m.m[4] = u.x;
m.m[5] = u.y;
m.m[6] = u.z;
m.m[8] = -f.x;
m.m[9] = -f.y;
m.m[10] = -f.z;
return quatFromMat4(m);
}
// a * b: applies b first, then a.
QuatT quatMultiply(QuatT a, QuatT b) {
QuatT out;
out.x = a.w * b.x + a.x * b.w + a.y * b.z - a.z * b.y;
out.y = a.w * b.y - a.x * b.z + a.y * b.w + a.z * b.x;
out.z = a.w * b.z + a.x * b.y - a.y * b.x + a.z * b.w;
out.w = a.w * b.w - a.x * b.x - a.y * b.y - a.z * b.z;
return out;
}
QuatT quatNormalize(QuatT q) {
float length = sqrtf(q.x * q.x + q.y * q.y + q.z * q.z + q.w * q.w);
if (length < EPSILON) {
return quatIdentity();
}
q.x /= length;
q.y /= length;
q.z /= length;
q.w /= length;
return q;
}
Vec3T quatRotate(QuatT q, Vec3T v) {
Vec3T u = vec3(q.x, q.y, q.z);
Vec3T t = vec3Scale(vec3Cross(u, v), 2.0f);
return vec3Add(vec3Add(v, vec3Scale(t, q.w)), vec3Cross(u, t));
}
QuatT quatSlerp(QuatT a, QuatT b, float t) {
QuatT out;
float cosTheta = a.x * b.x + a.y * b.y + a.z * b.z + a.w * b.w;
float theta;
float sinTheta;
float wa;
float wb;
// Take the short way round.
if (cosTheta < 0.0f) {
b.x = -b.x;
b.y = -b.y;
b.z = -b.z;
b.w = -b.w;
cosTheta = -cosTheta;
}
if (cosTheta > 1.0f - EPSILON) {
// Nearly parallel: lerp is accurate and avoids the division.
out.x = a.x + (b.x - a.x) * t;
out.y = a.y + (b.y - a.y) * t;
out.z = a.z + (b.z - a.z) * t;
out.w = a.w + (b.w - a.w) * t;
return quatNormalize(out);
}
theta = acosf(cosTheta);
sinTheta = sinf(theta);
wa = sinf((1.0f - t) * theta) / sinTheta;
wb = sinf(t * theta) / sinTheta;
out.x = a.x * wa + b.x * wb;
out.y = a.y * wa + b.y * wb;
out.z = a.z * wa + b.z * wb;
out.w = a.w * wa + b.w * wb;
return out;
}
// The inverse of quatFromEuler (Y, then X, then Z).
void quatToEuler(QuatT q, float *xDegrees, float *yDegrees, float *zDegrees) {
Mat4T m = mat4Compose(vec3(0.0f, 0.0f, 0.0f), q, vec3(1.0f, 1.0f, 1.0f));
float sinX = -m.m[9];
if (sinX > 1.0f) {
sinX = 1.0f;
}
if (sinX < -1.0f) {
sinX = -1.0f;
}
*xDegrees = RADIANS_TO_DEGREES(asinf(sinX));
if (fabsf(sinX) < 1.0f - EPSILON) {
*yDegrees = RADIANS_TO_DEGREES(atan2f(m.m[8], m.m[10]));
*zDegrees = RADIANS_TO_DEGREES(atan2f(m.m[1], m.m[5]));
} else {
// Gimbal lock: give all the twist to Y.
*yDegrees = RADIANS_TO_DEGREES(atan2f(-m.m[2], m.m[0]));
*zDegrees = 0.0f;
}
}
Vec3T vec3(float x, float y, float z) {
Vec3T out = { x, y, z };
return out;
}
Vec3T vec3Add(Vec3T a, Vec3T b) {
return vec3(a.x + b.x, a.y + b.y, a.z + b.z);
}
Vec3T vec3Cross(Vec3T a, Vec3T b) {
return vec3(a.y * b.z - a.z * b.y, a.z * b.x - a.x * b.z, a.x * b.y - a.y * b.x);
}
float vec3Dot(Vec3T a, Vec3T b) {
return a.x * b.x + a.y * b.y + a.z * b.z;
}
float vec3Length(Vec3T a) {
return sqrtf(vec3Dot(a, a));
}
Vec3T vec3Lerp(Vec3T a, Vec3T b, float t) {
return vec3Add(a, vec3Scale(vec3Subtract(b, a), t));
}
Vec3T vec3Normalize(Vec3T a) {
float length = vec3Length(a);
if (length < EPSILON) {
return vec3(0.0f, 0.0f, 0.0f);
}
return vec3Scale(a, 1.0f / length);
}
Vec3T vec3Scale(Vec3T a, float s) {
return vec3(a.x * s, a.y * s, a.z * s);
}
Vec3T vec3Subtract(Vec3T a, Vec3T b) {
return vec3(a.x - b.x, a.y - b.y, a.z - b.z);
}

90
src/math3d.h Normal file
View file

@ -0,0 +1,90 @@
/*
*
* Singe 3
* Copyright (C) 2006-2026 Scott Duensing <scott@kangaroopunch.com>
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
* as published by the Free Software Foundation; either version 3
* of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
* 02110-1301, USA.
*
*/
#ifndef MATH3D_H
#define MATH3D_H
#include <stdbool.h>
#include <stdint.h>
// Right-handed, +Y up, -Z forward (glTF's convention). Matrices are column major, as the GPU
// expects them; m[column * 4 + row].
typedef struct Vec3S {
float x;
float y;
float z;
} Vec3T;
typedef struct Vec4S {
float x;
float y;
float z;
float w;
} Vec4T;
typedef struct QuatS {
float x;
float y;
float z;
float w;
} QuatT;
typedef struct Mat4S {
float m[16];
} Mat4T;
Mat4T mat4Compose(Vec3T translation, QuatT rotation, Vec3T scale);
void mat4Decompose(Mat4T a, Vec3T *translation, QuatT *rotation, Vec3T *scale);
Mat4T mat4Identity(void);
bool mat4Invert(Mat4T a, Mat4T *out);
Mat4T mat4LookAt(Vec3T eye, Vec3T target, Vec3T up);
Mat4T mat4Multiply(Mat4T a, Mat4T b);
Mat4T mat4Orthographic(float width, float height, float near, float far);
Mat4T mat4Perspective(float fovDegrees, float aspect, float near, float far);
Vec3T mat4TransformPoint(Mat4T a, Vec3T p);
Vec3T mat4TransformVector(Mat4T a, Vec3T v);
Mat4T mat4Transpose(Mat4T a);
QuatT quatFromAxisAngle(Vec3T axis, float degrees);
QuatT quatFromEuler(float xDegrees, float yDegrees, float zDegrees);
QuatT quatFromMat4(Mat4T a);
QuatT quatIdentity(void);
QuatT quatLookRotation(Vec3T forward, Vec3T up);
QuatT quatMultiply(QuatT a, QuatT b);
QuatT quatNormalize(QuatT q);
Vec3T quatRotate(QuatT q, Vec3T v);
QuatT quatSlerp(QuatT a, QuatT b, float t);
void quatToEuler(QuatT q, float *xDegrees, float *yDegrees, float *zDegrees);
Vec3T vec3(float x, float y, float z);
Vec3T vec3Add(Vec3T a, Vec3T b);
Vec3T vec3Cross(Vec3T a, Vec3T b);
float vec3Dot(Vec3T a, Vec3T b);
float vec3Length(Vec3T a);
Vec3T vec3Lerp(Vec3T a, Vec3T b, float t);
Vec3T vec3Normalize(Vec3T a);
Vec3T vec3Scale(Vec3T a, float s);
Vec3T vec3Subtract(Vec3T a, Vec3T b);
#endif

1018
src/model.c Normal file

File diff suppressed because it is too large Load diff

50
src/model.h Normal file
View file

@ -0,0 +1,50 @@
/*
*
* Singe 3
* Copyright (C) 2006-2026 Scott Duensing <scott@kangaroopunch.com>
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
* as published by the Free Software Foundation; either version 3
* of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
* 02110-1301, USA.
*
*/
#ifndef MODEL_H
#define MODEL_H
#include <stdbool.h>
#include <stdint.h>
double animationGetTime(int32_t root);
bool animationIsPlaying(int32_t root);
bool animationPause(int32_t root);
bool animationPlay(int32_t root, int32_t index, bool loop, float speed);
bool animationResume(int32_t root);
bool animationSetTime(int32_t root, double seconds);
bool animationStop(int32_t root);
int32_t modelAnimationIndex(int32_t model, const char *name);
bool modelDelete(int32_t model);
int32_t modelGetAnimationCount(int32_t model);
const char *modelGetAnimationName(int32_t model, int32_t index);
int32_t modelInstance(int32_t model, int32_t parent);
const char *modelLastError(void);
int32_t modelLoad(const char *name);
void modelQuit(void);
int32_t modelRootOf(int32_t root);
void modelUpdate(bool advance);
bool modelValid(int32_t model);
#endif

2266
src/scene.c Normal file

File diff suppressed because it is too large Load diff

130
src/scene.h Normal file
View file

@ -0,0 +1,130 @@
/*
*
* Singe 3
* Copyright (C) 2006-2026 Scott Duensing <scott@kangaroopunch.com>
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
* as published by the Free Software Foundation; either version 3
* of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
* 02110-1301, USA.
*
*/
#ifndef SCENE_H
#define SCENE_H
#include <stdbool.h>
#include <stdint.h>
#include <SDL3/SDL.h>
#include "math3d.h"
#define SCENE_ROOT_NODE 0
// A vertex as the GPU sees it; model loaders fill these directly.
typedef struct SceneVertexS {
float position[3];
float normal[3];
float uv[2];
uint8_t joints[4];
float weights[4];
} SceneVertexT;
// Hands back a video player's current texture (NULL when it has none), for materialSetVideo.
typedef SDL_Texture *(*SceneVideoSourceFn)(int32_t player);
typedef enum LightTypeE {
LIGHT_DIRECTIONAL = 0,
LIGHT_POINT = 1,
LIGHT_SPOT = 2
} LightTypeE;
bool sceneAvailable(void);
bool sceneEnable(bool enabled);
void sceneGetSize(int32_t *width, int32_t *height);
bool sceneInit(SDL_GPUDevice *device, SDL_Renderer *renderer);
bool sceneIsEnabled(void);
bool sceneProject(Vec3T world, float *x, float *y, float *depth);
void sceneQuit(void);
SDL_Texture *sceneRender(void);
bool sceneResize(int32_t width, int32_t height);
void sceneSetAntialias(bool antialias);
void sceneSetAmbient(uint8_t r, uint8_t g, uint8_t b);
void sceneComputeNormals(SceneVertexT *vertices, int32_t vertexCount, const uint32_t *indices, int32_t indexCount);
void sceneSetBackground(uint8_t r, uint8_t g, uint8_t b, uint8_t a);
Vec3T sceneUnproject(float x, float y, float distance);
void sceneUpdateVideo(SceneVideoSourceFn source);
bool cameraSet(int32_t node);
void cameraSetOrthographic(float height, float near, float far);
void cameraSetPerspective(float fovDegrees, float near, float far);
bool lightAttach(int32_t node, LightTypeE type);
int32_t lightNew(LightTypeE type, int32_t parent);
bool lightSetColor(int32_t node, uint8_t r, uint8_t g, uint8_t b);
bool lightSetCone(int32_t node, float innerDegrees, float outerDegrees);
bool lightSetIntensity(int32_t node, float intensity);
bool lightSetRange(int32_t node, float range);
bool materialDelete(int32_t material);
int32_t materialNew(void);
bool materialSetBlend(int32_t material, bool blend);
bool materialSetColor(int32_t material, uint8_t r, uint8_t g, uint8_t b, uint8_t a);
bool materialSetDoubleSided(int32_t material, bool doubleSided);
bool materialSetEmissive(int32_t material, uint8_t r, uint8_t g, uint8_t b);
bool materialSetMetallic(int32_t material, float metallic);
bool materialSetRoughness(int32_t material, float roughness);
bool materialSetTexture(int32_t material, SDL_Surface *image);
bool materialSetUnlit(int32_t material, bool unlit);
bool materialSetVideo(int32_t material, int32_t player);
bool materialValid(int32_t material);
int32_t meshBox(float width, float height, float depth);
int32_t meshCone(float radius, float height, int32_t segments);
int32_t meshCylinder(float radius, float height, int32_t segments);
bool meshDelete(int32_t mesh);
int32_t meshNew(const float *positions, const float *normals, const float *uvs, int32_t vertexCount, const uint32_t *indices, int32_t indexCount);
int32_t meshNewVertices(const SceneVertexT *vertices, int32_t vertexCount, const uint32_t *indices, int32_t indexCount, bool skinned);
int32_t meshPlane(float width, float depth);
int32_t meshSphere(float radius, int32_t segments);
int32_t meshTorus(float radius, float tubeRadius, int32_t segments);
bool meshValid(int32_t mesh);
bool nodeDelete(int32_t node);
int32_t nodeFind(int32_t root, const char *name);
int32_t nodeGetChild(int32_t node, int32_t index);
int32_t nodeGetChildCount(int32_t node);
uint32_t nodeGetGeneration(int32_t node);
const char *nodeGetName(int32_t node);
int32_t nodeGetParent(int32_t node);
Vec3T nodeGetPosition(int32_t node);
QuatT nodeGetRotation(int32_t node);
Vec3T nodeGetScale(int32_t node);
Vec3T nodeGetWorldPosition(int32_t node);
bool nodeLookAt(int32_t node, Vec3T target);
bool nodeMove(int32_t node, Vec3T delta);
int32_t nodeNew(int32_t parent);
bool nodeRotate(int32_t node, QuatT delta);
bool nodeSetMesh(int32_t node, int32_t mesh, int32_t material);
bool nodeSetName(int32_t node, const char *name);
bool nodeSetParent(int32_t node, int32_t parent);
bool nodeSetPosition(int32_t node, Vec3T position);
bool nodeSetRotation(int32_t node, QuatT rotation);
bool nodeSetScale(int32_t node, Vec3T scale);
bool nodeSetSkin(int32_t node, const int32_t *joints, const Mat4T *inverseBind, int32_t count);
bool nodeSetVisible(int32_t node, bool visible);
bool nodeValid(int32_t node);
#endif

61
src/shaders/build.sh Executable file
View file

@ -0,0 +1,61 @@
#!/bin/bash
#
# Compiles scene.hlsl into sceneShaders.h: SPIR-V (Vulkan), DXIL (Direct3D 12) and MSL (Metal)
# for each entry point, as C arrays. Needs SDL_shadercross's command line tool (built from
# https://github.com/libsdl-org/SDL_shadercross with SDLSHADERCROSS_DXC=ON); pass its path as the
# first argument or put it on PATH. The engine build never runs this: the output is checked in.
#
# Usage: src/shaders/build.sh [path/to/shadercross]
set -euo pipefail
cd "$(dirname "$0")"
SHADERCROSS=${1:-shadercross}
OUT=sceneShaders.h
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
ENTRIES="vertexStatic:vertex vertexSkinned:vertex fragmentMain:fragment"
emit() {
# emit NAME FILE: a C array from a binary file
local name=$1
local file=$2
echo "static const unsigned char ${name}[] = {"
od -An -v -tx1 "$file" | sed 's/ \([0-9a-f][0-9a-f]\)/0x\1,/g; s/^/\t/'
echo "};"
}
{
echo "// Generated by build.sh from scene.hlsl with SDL_shadercross; do not edit."
echo "// SPIR-V for Vulkan, DXIL for Direct3D 12, MSL for Metal, one set per entry point."
echo
echo "#ifndef SCENE_SHADERS_H"
echo "#define SCENE_SHADERS_H"
echo
echo "#include <stddef.h>"
echo
echo "typedef struct SceneShaderS {"
echo " const char *entryPoint;"
echo " const unsigned char *spirv;"
echo " size_t spirvSize;"
echo " const unsigned char *dxil;"
echo " size_t dxilSize;"
echo " const unsigned char *msl;"
echo " size_t mslSize;"
echo "} SceneShaderT;"
echo
for entry in $ENTRIES; do
name=${entry%%:*}
stage=${entry##*:}
for format in SPIRV DXIL MSL; do
ext=$(echo "$format" | tr '[:upper:]' '[:lower:]')
"$SHADERCROSS" scene.hlsl -s HLSL -d "$format" -t "$stage" -e "$name" -o "$TMP/$name.$ext" >&2
emit "_${name}${format}" "$TMP/$name.$ext"
echo
done
echo "static const SceneShaderT sceneShader$(echo "${name:0:1}" | tr '[:lower:]' '[:upper:]')${name:1} = { \"$name\", _${name}SPIRV, sizeof(_${name}SPIRV), _${name}DXIL, sizeof(_${name}DXIL), _${name}MSL, sizeof(_${name}MSL) };"
echo
done
echo "#endif"
} > "$OUT"
echo "Wrote $OUT"

166
src/shaders/scene.hlsl Normal file
View file

@ -0,0 +1,166 @@
// Singe 3 scene shaders: one vertex shader for static meshes, one for skinned meshes, one
// fragment shader for both. Compiled offline by src/shaders/build.sh (SDL_shadercross) into the
// SPIR-V, DXIL and MSL blobs in sceneShaders.h; the engine build never compiles shaders.
//
// Resource bindings follow SDL_GPU's HLSL convention: vertex uniforms in space1, fragment
// textures and samplers in space2, fragment uniforms in space3.
#define MAX_LIGHTS 8
#define MAX_JOINTS 128
#define LIGHT_DIRECTIONAL 0
#define LIGHT_POINT 1
#define LIGHT_SPOT 2
// ----- Vertex -----
cbuffer DrawUniforms : register(b0, space1) {
float4x4 modelViewProjection;
float4x4 model;
float4x4 normalMatrix; // Inverse transpose of model, for normals under non-uniform scale
};
cbuffer SkinUniforms : register(b1, space1) {
float4x4 joints[MAX_JOINTS];
};
struct VertexInput {
float3 position : TEXCOORD0;
float3 normal : TEXCOORD1;
float2 uv : TEXCOORD2;
uint4 joints : TEXCOORD3;
float4 weights : TEXCOORD4;
};
struct VertexOutput {
float4 position : SV_Position;
float3 worldPosition : TEXCOORD0;
float3 worldNormal : TEXCOORD1;
float2 uv : TEXCOORD2;
};
VertexOutput vertexStatic(VertexInput input) {
VertexOutput output;
output.position = mul(modelViewProjection, float4(input.position, 1.0));
output.worldPosition = mul(model, float4(input.position, 1.0)).xyz;
output.worldNormal = normalize(mul((float3x3)normalMatrix, input.normal));
output.uv = input.uv;
return output;
}
VertexOutput vertexSkinned(VertexInput input) {
VertexOutput output;
float4 position = float4(input.position, 1.0);
float4 skinned;
float3 skinnedNormal;
// The weighted sum of the joint transforms, applied to the position and the normal.
skinned = mul(joints[input.joints.x], position) * input.weights.x
+ mul(joints[input.joints.y], position) * input.weights.y
+ mul(joints[input.joints.z], position) * input.weights.z
+ mul(joints[input.joints.w], position) * input.weights.w;
skinnedNormal = mul((float3x3)joints[input.joints.x], input.normal) * input.weights.x
+ mul((float3x3)joints[input.joints.y], input.normal) * input.weights.y
+ mul((float3x3)joints[input.joints.z], input.normal) * input.weights.z
+ mul((float3x3)joints[input.joints.w], input.normal) * input.weights.w;
output.position = mul(modelViewProjection, skinned);
output.worldPosition = mul(model, skinned).xyz;
output.worldNormal = normalize(mul((float3x3)normalMatrix, skinnedNormal));
output.uv = input.uv;
return output;
}
// ----- Fragment -----
struct Light {
float4 positionType; // xyz position (point, spot) or unused; w = type
float4 directionRange; // xyz direction the light shines in (directional, spot); w = range (0 = infinite)
float4 color; // rgb already multiplied by intensity
float4 cone; // x = cos(inner), y = cos(outer)
};
cbuffer FragmentUniforms : register(b0, space3) {
float4 cameraPosition;
float4 ambient;
float4 baseColor;
float4 emissive;
float4 material; // x = metallic, y = roughness, z = unlit (1/0), w = textured (1/0)
float4 counts; // x = light count
Light lights[MAX_LIGHTS];
};
Texture2D<float4> baseTexture : register(t0, space2);
SamplerState baseSampler : register(s0, space2);
// Named fragmentMain because "fragment" is a keyword in Metal and the MSL entry point keeps this name.
float4 fragmentMain(VertexOutput input) : SV_Target {
float4 albedo = baseColor;
float3 normal;
float3 view;
float3 diffuseColor;
float3 specularColor;
float3 result;
float shininess;
float metallic = material.x;
float roughness = material.y;
int lightCount;
int x;
if (material.w > 0.5) {
albedo *= baseTexture.Sample(baseSampler, input.uv);
}
if (material.z > 0.5) {
return albedo;
}
// Metallic surfaces reflect their own colour and have little diffuse; dielectrics reflect a
// little white. Roughness sets how tight the highlight is. Without an environment to
// reflect, metals would go black away from highlights, so they keep some diffuse and get a
// stronger ambient term standing in for reflections.
normal = normalize(input.worldNormal);
view = normalize(cameraPosition.xyz - input.worldPosition);
diffuseColor = albedo.rgb * (1.0 - 0.6 * metallic);
specularColor = lerp(float3(0.04, 0.04, 0.04), albedo.rgb, metallic) * (1.0 + 2.0 * metallic);
shininess = lerp(256.0, 4.0, roughness * roughness);
result = ambient.rgb * albedo.rgb * (1.0 + 2.0 * metallic) + emissive.rgb;
lightCount = (int)counts.x;
for (x = 0; x < MAX_LIGHTS; x++) {
float3 toLight;
float attenuation = 1.0;
float distance;
float diffuse;
float specular;
float3 halfway;
// A break here crashes DXC; continue costs nothing worth measuring.
if (x >= lightCount) {
continue;
}
if (lights[x].positionType.w == LIGHT_DIRECTIONAL) {
toLight = normalize(-lights[x].directionRange.xyz);
} else {
toLight = lights[x].positionType.xyz - input.worldPosition;
distance = length(toLight);
toLight = toLight / max(distance, 0.0001);
attenuation = 1.0 / (1.0 + distance * distance);
if (lights[x].directionRange.w > 0.0) {
// Fade to nothing at the range so lights do not pop.
attenuation *= saturate(1.0 - pow(distance / lights[x].directionRange.w, 4.0));
}
if (lights[x].positionType.w == LIGHT_SPOT) {
float cosAngle = dot(-toLight, normalize(lights[x].directionRange.xyz));
attenuation *= smoothstep(lights[x].cone.y, lights[x].cone.x, cosAngle);
}
}
diffuse = saturate(dot(normal, toLight));
halfway = normalize(toLight + view);
specular = pow(saturate(dot(normal, halfway)), shininess) * (1.0 - roughness * 0.5);
result += (diffuseColor * diffuse + specularColor * specular * diffuse) * lights[x].color.rgb * attenuation;
}
return float4(result, albedo.a);
}

2992
src/shaders/sceneShaders.h Normal file

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -93,7 +93,7 @@ typedef struct ConfigS {
ConfigT *confFromDatabase(const ConfigT *conf);
void singe(SDL_Window *window, SDL_Renderer *renderer, ConfigT *conf);
void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, ConfigT *conf);
#endif // SINGE_H

7
thirdparty/cgltf/LICENSE vendored Normal file
View file

@ -0,0 +1,7 @@
Copyright (c) 2018-2021 Johannes Kuhlmann
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

7175
thirdparty/cgltf/cgltf.h vendored Normal file

File diff suppressed because it is too large Load diff