Implement CRT shader effect support
continuous-integration/drone/push Build is passing Details

This commit is contained in:
kayomn 2024-07-21 18:59:28 +01:00
parent 56342d9d8e
commit 67dee07f8e
9 changed files with 332 additions and 154 deletions

52
debug/crt.frag Normal file
View File

@ -0,0 +1,52 @@
#version 430
layout (binding = 0) uniform sampler2D sprite;
layout (location = 0) in vec4 color;
layout (location = 1) in vec2 uv;
layout (location = 0) out vec4 texel;
layout (binding = 0) uniform Effect {
float screen_width;
float screen_height;
float time;
};
vec3 scanline(vec2 coord, vec3 screen)
{
screen.rgb -= sin((coord.y + (time * 29.0))) * 0.02;
return screen;
}
vec2 crt(vec2 coord, float bend)
{
// put in symmetrical coords
coord = (coord - 0.5) * 2.0;
coord *= 1.0;
// deform coords
coord.x *= 1.0 + pow((abs(coord.y) / bend), 2.0);
coord.y *= 1.0 + pow((abs(coord.x) / bend), 2.0);
// transform back to 0.0 - 1.0 space
coord = (coord / 2.0) + 0.5;
return coord;
}
void main()
{
vec2 crtCoords = crt(uv, 4.8);
// Split the color channels
texel.rgb = texture(sprite, crtCoords).rgb;
texel.a = 1;
// HACK: this bend produces a shitty moire pattern.
// Up the bend for the scanline
vec2 screenSpace = crtCoords * vec2(screen_width, screen_height);
texel.rgb = scanline(screenSpace, texel.rgb);
}

View File

@ -9,11 +9,20 @@ const ChromaticAberration = extern struct {
padding: [12]u8 = undefined,
};
const CRT = extern struct {
width: f32,
height: f32,
time: f32,
padding: [4]u8 = undefined,
};
const Actors = struct {
instances: coral.stack.Sequential(@Vector(2, f32)) = .{.allocator = coral.heap.allocator},
body_texture: ona.gfx.Texture = .default,
render_texture: ona.gfx.Texture = .default,
ca_effect: ona.gfx.Effect = .default,
crt_effect: ona.gfx.Effect = .default,
staging_texture: ona.gfx.Texture = .default,
};
const Player = struct {
@ -44,6 +53,7 @@ fn load(config: ona.Write(ona.gfx.Config), actors: ona.Write(Actors), assets: on
});
actors.res.ca_effect = try assets.res.load_effect_file(coral.files.bundle, "./ca.frag.spv");
actors.res.crt_effect = try assets.res.load_effect_file(coral.files.bundle, "./crt.frag.spv");
try actors.res.instances.push_grow(.{0, 0});
}
@ -52,22 +62,46 @@ fn exit(actors: ona.Write(Actors)) void {
actors.res.instances.deinit();
}
fn render(commands: ona.gfx.Commands, actors: ona.Write(Actors)) !void {
try commands.set_effect(actors.res.ca_effect, ChromaticAberration{
.effect_magnitude = 15.0,
fn render(commands: ona.gfx.Commands, actors: ona.Write(Actors), app: ona.Read(ona.App)) !void {
try commands.set_target(.{
.texture = actors.res.render_texture,
.clear_color = ona.gfx.colors.black,
.clear_depth = 0,
.clear_stencil = 0,
});
for (actors.res.instances.values) |instance| {
try commands.draw_texture(.{
.texture = actors.res.body_texture,
try commands.draw_texture(.{
.texture = .default,
.transform = .{
.origin = instance,
.xbasis = .{64, 0},
.ybasis = .{0, 64},
},
});
}
.transform = .{
.origin = .{1280 / 2, 720 / 2},
.xbasis = .{1280, 0},
.ybasis = .{0, 720},
},
});
try commands.set_effect(actors.res.crt_effect, CRT{
.width = 1280,
.height = 720,
.time = @floatCast(app.res.elapsed_time),
});
try commands.set_target(.{
.texture = .backbuffer,
.clear_color = null,
.clear_depth = null,
.clear_stencil = null,
});
try commands.draw_texture(.{
.texture = actors.res.render_texture,
.transform = .{
.origin = .{1280 / 2, 720 / 2},
.xbasis = .{1280, 0},
.ybasis = .{0, 720},
},
});
}
fn update(player: ona.Read(Player), actors: ona.Write(Actors), mapping: ona.Read(ona.act.Mapping)) !void {

View File

@ -4,6 +4,7 @@ const flow = @import("flow");
events: *const Events,
target_frame_time: f64,
elapsed_time: f64,
is_running: bool,
pub const Events = struct {

View File

@ -17,33 +17,40 @@ fn Handle(comptime HandleDesc: type) type {
};
}
pub const Texture = Handle(struct {
format: Format,
access: Access,
pub const Texture = enum (u32) {
default,
backbuffer,
_,
pub const Access = union (enum) {
static: Static,
render: Render,
pub const Desc = struct {
format: Format,
access: Access,
pub const Static = struct {
width: u16,
data: []const coral.io.Byte,
};
pub const Access = union (enum) {
static: Static,
render: Render,
pub const Render = struct {
width: u16,
height: u16,
};
};
pub const Format = enum {
rgba8,
bgra8,
pub fn byte_size(self: Format) usize {
return switch (self) {
.rgba8, .bgra8 => 4,
pub const Static = struct {
width: u16,
data: []const coral.io.Byte,
};
}
pub const Render = struct {
width: u16,
height: u16,
};
};
pub const Format = enum {
rgba8,
bgra8,
pub fn byte_size(self: Format) usize {
return switch (self) {
.rgba8, .bgra8 => 4,
};
}
};
};
});
};

View File

@ -30,7 +30,7 @@ pub const Command = union (enum) {
};
pub const SetTarget = struct {
texture: ?handles.Texture = null,
texture: handles.Texture,
clear_color: ?lina.Color,
clear_depth: ?f32,
clear_stencil: ?u8,
@ -135,7 +135,7 @@ const Frame = struct {
drawn_count: usize = 0,
flushed_count: usize = 0,
current_source_texture: handles.Texture = .default,
current_target_texture: ?handles.Texture = null,
current_target_texture: handles.Texture = .backbuffer,
current_effect: handles.Effect = .default,
const DrawTexture = extern struct {
@ -185,42 +185,39 @@ const Frame = struct {
bindings.vertex_buffers[vertex_indices.mesh] = quad_vertex_buffer;
switch (pools.textures.get(@intFromEnum(self.current_source_texture)).?.access) {
switch (pools.get_texture(self.current_source_texture).?.access) {
.render => |render| {
bindings.fs.images[0] = render.color_image;
bindings.fs.samplers[0] = render.sampler;
bindings.fs.samplers[0] = default_sampler;
},
.static => |static| {
bindings.fs.images[0] = static.image;
bindings.fs.samplers[0] = static.sampler;
bindings.fs.samplers[0] = default_sampler;
},
.empty => {
@panic("Cannot render empty textures");
},
}
const effect = pools.effects.get(@intFromEnum(self.current_effect)).?;
const effect = pools.get_effect(self.current_effect).?;
sokol.gfx.applyPipeline(effect.pipeline);
if (self.current_target_texture) |target_texture| {
const texture = pools.textures.get(@intFromEnum(target_texture)).?;
const texture = pools.get_texture(self.current_target_texture).?;
sokol.gfx.applyUniforms(.VS, 0, sokol.gfx.asRange(&lina.orthographic_projection(-1.0, 1.0, .{
.left = 0,
.top = 0,
.right = @floatFromInt(texture.width),
.bottom = @floatFromInt(texture.height),
})));
} else {
sokol.gfx.applyUniforms(.VS, 0, sokol.gfx.asRange(&lina.orthographic_projection(-1.0, 1.0, .{
.left = 0,
.top = 0,
.right = @floatFromInt(self.swapchain.width),
.bottom = @floatFromInt(self.swapchain.height),
})));
sokol.gfx.applyUniforms(.VS, 0, sokol.gfx.asRange(&lina.orthographic_projection(-1.0, 1.0, .{
.left = 0,
.top = 0,
.right = @floatFromInt(texture.width),
.bottom = @floatFromInt(texture.height),
})));
if (effect.properties.len != 0) {
sokol.gfx.applyUniforms(.FS, 0, sokol.gfx.asRange(effect.properties));
}
sokol.gfx.applyUniforms(.FS, 0, sokol.gfx.asRange(effect.properties));
while (true) {
const buffer_index = self.flushed_count / batches_per_buffer;
const buffer_offset = self.flushed_count % batches_per_buffer;
@ -246,7 +243,7 @@ const Frame = struct {
self.current_effect = command.effect;
if (pools.effects.get(@intFromEnum(self.current_effect))) |effect| {
if (pools.get_effect(self.current_effect)) |effect| {
@memcpy(effect.properties, command.properties);
}
}
@ -280,17 +277,13 @@ const Frame = struct {
pass.action.depth = .{.load_action = .LOAD};
}
if (command.texture) |texture| {
pass.attachments = switch (pools.textures.get(@intFromEnum(texture)).?.access) {
.static => @panic("Cannot render to static textures"),
.render => |render| render.attachments,
};
pass.attachments = switch (pools.get_texture(self.current_target_texture).?.access) {
.static => @panic("Cannot render to static textures"),
.empty => @panic("Cannot render to empty textures"),
.render => |render| render.attachments,
};
self.current_target_texture = command.texture;
} else {
pass.swapchain = self.swapchain;
self.current_target_texture = null;
}
self.current_target_texture = command.texture;
sokol.gfx.beginPass(pass);
}
@ -313,7 +306,7 @@ const Pools = struct {
const TexturePool = coral.Pool(resources.Texture);
fn create_effect(self: *Pools, desc: handles.Effect.Desc) !handles.Effect {
pub fn create_effect(self: *Pools, desc: handles.Effect.Desc) !handles.Effect {
var effect = try resources.Effect.init(desc);
errdefer effect.deinit();
@ -321,7 +314,7 @@ const Pools = struct {
return @enumFromInt(try self.effects.insert(effect));
}
fn create_texture(self: *Pools, desc: handles.Texture.Desc) !handles.Texture {
pub fn create_texture(self: *Pools, desc: handles.Texture.Desc) !handles.Texture {
var texture = try resources.Texture.init(desc);
errdefer texture.deinit();
@ -329,7 +322,7 @@ const Pools = struct {
return @enumFromInt(try self.textures.insert(texture));
}
fn deinit(self: *Pools) void {
pub fn deinit(self: *Pools) void {
var textures = self.textures.values();
while (textures.next()) |texture| {
@ -339,7 +332,7 @@ const Pools = struct {
self.textures.deinit();
}
fn destroy_effect(self: *Pools, handle: handles.Effect) bool {
pub fn destroy_effect(self: *Pools, handle: handles.Effect) bool {
switch (handle) {
.default => {},
@ -355,7 +348,7 @@ const Pools = struct {
return true;
}
fn destroy_texture(self: *Pools, handle: handles.Texture) bool {
pub fn destroy_texture(self: *Pools, handle: handles.Texture) bool {
switch (handle) {
.default => {},
@ -371,7 +364,15 @@ const Pools = struct {
return true;
}
fn init(allocator: std.mem.Allocator) !Pools {
pub fn get_effect(self: *Pools, handle: handles.Effect) ?*resources.Effect {
return self.effects.get(@intFromEnum(handle));
}
pub fn get_texture(self: *Pools, handle: handles.Texture) ?*resources.Texture {
return self.textures.get(@intFromEnum(handle));
}
pub fn init(allocator: std.mem.Allocator) !Pools {
var pools = Pools{
.effects = EffectPool.init(allocator),
.textures = TexturePool.init(allocator),
@ -381,31 +382,47 @@ const Pools = struct {
pools.deinit();
}
_ = try pools.create_effect(.{
.fragment_spirv_ops = &spirv.to_ops(@embedFile("./shaders/2d_default.frag.spv")),
});
const default_texture_data = [_]u32{
0x000000FF, 0x800080FF, 0x000000FF, 0x800080FF, 0x000000FF, 0x800080FF, 0x000000FF, 0x800080FF,
0x800080FF, 0x000000FF, 0x800080FF, 0x000000FF, 0x800080FF, 0x000000FF, 0x800080FF, 0x000000FF,
0x000000FF, 0x800080FF, 0x000000FF, 0x800080FF, 0x000000FF, 0x800080FF, 0x000000FF, 0x800080FF,
0x800080FF, 0x000000FF, 0x800080FF, 0x000000FF, 0x800080FF, 0x000000FF, 0x800080FF, 0x000000FF,
0x000000FF, 0x800080FF, 0x000000FF, 0x800080FF, 0x000000FF, 0x800080FF, 0x000000FF, 0x800080FF,
0x800080FF, 0x000000FF, 0x800080FF, 0x000000FF, 0x800080FF, 0x000000FF, 0x800080FF, 0x000000FF,
0x000000FF, 0x800080FF, 0x000000FF, 0x800080FF, 0x000000FF, 0x800080FF, 0x000000FF, 0x800080FF,
0x800080FF, 0x000000FF, 0x800080FF, 0x000000FF, 0x800080FF, 0x000000FF, 0x800080FF, 0x000000FF,
const assert = struct {
fn is_handle(expected: anytype, actual: @TypeOf(expected)) void {
std.debug.assert(actual == expected);
}
};
_ = try pools.create_texture(.{
assert.is_handle(handles.Effect.default, try pools.create_effect(.{
.fragment_spirv_ops = &spirv.to_ops(@embedFile("./shaders/2d_default.frag.spv")),
}));
assert.is_handle(handles.Texture.default, try pools.create_texture(.{
.format = .rgba8,
.access = .{
.static = .{
.data = std.mem.asBytes(&default_texture_data),
.data = std.mem.asBytes(&[_]u32{
0xFF000000, 0xFF800080, 0xFF000000, 0xFF800080, 0xFF000000, 0xFF800080, 0xFF000000, 0xFF800080,
0xFF800080, 0xFF000000, 0xFF800080, 0xFF000000, 0xFF800080, 0xFF000000, 0xFF800080, 0xFF000000,
0xFF000000, 0xFF800080, 0xFF000000, 0xFF800080, 0xFF000000, 0xFF800080, 0xFF000000, 0xFF800080,
0xFF800080, 0xFF000000, 0xFF800080, 0xFF000000, 0xFF800080, 0xFF000000, 0xFF800080, 0xFF000000,
0xFF000000, 0xFF800080, 0xFF000000, 0xFF800080, 0xFF000000, 0xFF800080, 0xFF000000, 0xFF800080,
0xFF800080, 0xFF000000, 0xFF800080, 0xFF000000, 0xFF800080, 0xFF000000, 0xFF800080, 0xFF000000,
0xFF000000, 0xFF800080, 0xFF000000, 0xFF800080, 0xFF000000, 0xFF800080, 0xFF000000, 0xFF800080,
0xFF800080, 0xFF000000, 0xFF800080, 0xFF000000, 0xFF800080, 0xFF000000, 0xFF800080, 0xFF000000,
}),
.width = 8,
},
},
});
}));
assert.is_handle(handles.Texture.backbuffer, try pools.create_texture(.{
.format = .rgba8,
.access = .{
.render = .{
.width = 0,
.height = 0,
},
}
}));
return pools;
}
@ -457,6 +474,8 @@ pub const Work = union (enum) {
var pending: coral.asyncio.BlockingQueue(1024, Work) = .{};
};
var default_sampler: sokol.gfx.Sampler = undefined;
pub fn enqueue_work(work: Work) void {
Work.pending.enqueue(work);
}
@ -541,6 +560,8 @@ fn run(window: *ext.SDL_Window) !void {
.type = .VERTEXBUFFER,
});
default_sampler = sokol.gfx.makeSampler(.{});
var has_commands_head: ?*Commands = null;
var has_commands_tail: ?*Commands = null;
@ -588,6 +609,23 @@ fn run(window: *ext.SDL_Window) !void {
},
.render_frame => |render_frame| {
const backbuffer = pools.get_texture(.backbuffer).?;
if (backbuffer.width != render_frame.width or backbuffer.height != render_frame.height) {
backbuffer.deinit();
backbuffer.* = try resources.Texture.init(.{
.format = .rgba8,
.access = .{
.render = .{
.width = render_frame.width,
.height = render_frame.height,
},
},
});
}
var frame = Frame{
.swapchain = .{
.width = render_frame.width,
@ -599,6 +637,51 @@ fn run(window: *ext.SDL_Window) !void {
}
};
sokol.gfx.beginPass(pass: {
var pass = sokol.gfx.Pass{
.action = .{
.stencil = .{
.load_action = .CLEAR,
},
.depth = .{
.load_action = .CLEAR,
.clear_value = 0,
}
},
};
pass.action.colors[0] = .{
.load_action = .CLEAR,
.clear_value = @bitCast(render_frame.clear_color),
};
pass.attachments = pools.get_texture(.backbuffer).?.access.render.attachments;
break: pass pass;
});
var has_commands = has_commands_head;
while (has_commands) |commands| : (has_commands = commands.has_next) {
for (commands.submitted_commands()) |command| {
try command.process(&pools, &frame);
}
frame.flush(&pools);
if (frame.current_target_texture != .backbuffer) {
frame.set_target(&pools, .{
.texture = .backbuffer,
.clear_color = null,
.clear_depth = null,
.clear_stencil = null,
});
}
}
sokol.gfx.endPass();
sokol.gfx.beginPass(swapchain_pass: {
var pass = sokol.gfx.Pass{
.swapchain = frame.swapchain,
@ -609,29 +692,20 @@ fn run(window: *ext.SDL_Window) !void {
},
};
pass.action.colors[0] = .{
.clear_value = @bitCast(render_frame.clear_color),
.load_action = .CLEAR,
};
pass.action.colors[0] = .{.load_action = .CLEAR};
break: swapchain_pass pass;
});
var has_commands = has_commands_head;
try frame.draw_texture(&pools, .{
.texture = .backbuffer,
while (has_commands) |commands| : (has_commands = commands.has_next) {
for (commands.submitted_commands()) |command| {
try command.process(&pools, &frame);
}
if (frame.current_target_texture != .default) {
frame.set_target(&pools, .{
.clear_color = null,
.clear_depth = null,
.clear_stencil = null,
});
}
}
.transform = .{
.origin = .{@as(f32, @floatFromInt(render_frame.width)) / 2, @as(f32, @floatFromInt(render_frame.height)) / 2},
.xbasis = .{@floatFromInt(render_frame.width), 0},
.ybasis = .{0, @floatFromInt(render_frame.height)},
},
});
frame.flush(&pools);
sokol.gfx.endPass();

View File

@ -123,11 +123,11 @@ pub const Effect = struct {
.float2 => .FLOAT2,
.float3 => .FLOAT3,
.float4 => .FLOAT4,
.int => .INT,
.int2 => .INT2,
.int3 => .INT3,
.int4 => .INT4,
.mat4 => .MAT4,
.integer => .INT,
.integer2 => .INT2,
.integer3 => .INT3,
.integer4 => .INT4,
.matrix4 => .MAT4,
},
.name = uniform.name,
@ -244,19 +244,18 @@ pub const Texture = struct {
access: Access,
pub const Access = union (enum) {
empty,
render: RenderAccess,
static: StaticAccess,
};
pub const RenderAccess = struct {
sampler: sokol.gfx.Sampler,
color_image: sokol.gfx.Image,
depth_image: sokol.gfx.Image,
attachments: sokol.gfx.Attachments,
};
pub const StaticAccess = struct {
sampler: sokol.gfx.Sampler,
image: sokol.gfx.Image,
};
@ -265,14 +264,14 @@ pub const Texture = struct {
.render => |render| {
sokol.gfx.destroyImage(render.color_image);
sokol.gfx.destroyImage(render.depth_image);
sokol.gfx.destroySampler(render.sampler);
sokol.gfx.destroyAttachments(render.attachments);
},
.static => |static| {
sokol.gfx.destroyImage(static.image);
sokol.gfx.destroySampler(static.sampler);
},
.empty => {},
}
self.* = undefined;
@ -286,6 +285,14 @@ pub const Texture = struct {
switch (desc.access) {
.render => |render| {
if (render.width == 0 or render.height == 0) {
return .{
.width = render.width,
.height = render.height,
.access = .empty,
};
}
const color_image = sokol.gfx.makeImage(.{
.pixel_format = pixel_format,
.width = render.width,
@ -314,8 +321,6 @@ pub const Texture = struct {
break: attachments_desc attachments_desc;
});
const sampler = sokol.gfx.makeSampler(.{});
return .{
.width = render.width,
.height = render.height,
@ -323,11 +328,10 @@ pub const Texture = struct {
.access = .{
.render = .{
.attachments = attachments,
.sampler = sampler,
.color_image = color_image,
.depth_image = depth_image,
},
}
},
};
},
@ -336,6 +340,14 @@ pub const Texture = struct {
return error.OutOfMemory;
};
if (static.width == 0 or height == 0) {
return .{
.width = static.width,
.height = height,
.access = .empty,
};
}
const image = sokol.gfx.makeImage(image_desc: {
var image_desc = sokol.gfx.ImageDesc{
.height = height,
@ -348,10 +360,7 @@ pub const Texture = struct {
break: image_desc image_desc;
});
const sampler = sokol.gfx.makeSampler(.{});
errdefer {
sokol.gfx.destroySampler(sampler);
sokol.gfx.destroyImage(image);
}
@ -362,7 +371,6 @@ pub const Texture = struct {
.access = .{
.static = .{
.image = image,
.sampler = sampler,
},
},
};

View File

@ -21,8 +21,9 @@ void main() {
const vec2 world_position = instance_origin + mesh_xy.x * instance_xbasis + mesh_xy.y * instance_ybasis;
const vec2 projected_position = (projection_matrix * vec4(world_position, 0, 1)).xy;
const vec2 rect_size = instance_rect.zw - instance_rect.xy;
const vec4 screen_position = vec4(projected_position, instance_depth, 1.0);
gl_Position = vec4(projected_position, instance_depth, 1.0);
gl_Position = screen_position;
color = instance_tint;
uv = instance_rect.xy + (mesh_uv * rect_size);
}

View File

@ -67,11 +67,11 @@ pub const Stage = struct {
float2,
float3,
float4,
int,
int2,
int3,
int4,
mat4,
integer,
integer2,
integer3,
integer4,
matrix4,
};
};
@ -82,29 +82,27 @@ pub const Stage = struct {
pub const max_uniforms = 16;
pub fn size(self: UniformBlock) usize {
const alignment: usize = switch (self.layout) {
.std140 => 16,
};
var accumulated_size: usize = 0;
for (self.uniforms) |uniform| {
const type_size = @max(1, uniform.len) * @as(usize, switch (uniform.type) {
accumulated_size += @max(1, uniform.len) * @as(usize, switch (uniform.type) {
.float => 4,
.float2 => 8,
.float3 => 12,
.float4 => 16,
.int => 4,
.int2 => 8,
.int3 => 12,
.int4 => 16,
.mat4 => 64,
.integer => 4,
.integer2 => 8,
.integer3 => 12,
.integer4 => 16,
.matrix4 => 64,
});
accumulated_size += (type_size + (alignment - 1)) & ~(alignment - 1);
}
return accumulated_size;
const alignment: usize = switch (self.layout) {
.std140 => 16,
};
return (accumulated_size + (alignment - 1)) & ~(alignment - 1);
}
};
@ -212,7 +210,7 @@ pub const Stage = struct {
.type = try switch (ext.spvc_type_get_basetype(member_type_handle)) {
ext.SPVC_BASETYPE_FP32 => switch (ext.spvc_type_get_vector_size(member_type_handle)) {
4 => switch (ext.spvc_type_get_columns(member_type_handle)) {
4 => Uniform.Type.mat4,
4 => Uniform.Type.matrix4,
1 => Uniform.Type.float4,
else => error.UnsupportedSPIRV,
},
@ -224,10 +222,10 @@ pub const Stage = struct {
},
ext.SPVC_BASETYPE_INT32 => try switch (ext.spvc_type_get_vector_size(member_type_handle)) {
1 => Uniform.Type.int,
2 => Uniform.Type.int2,
3 => Uniform.Type.int3,
4 => Uniform.Type.int4,
1 => Uniform.Type.integer,
2 => Uniform.Type.integer2,
3 => Uniform.Type.integer3,
4 => Uniform.Type.integer4,
else => error.UnsupportedSPIRV,
},

View File

@ -75,6 +75,7 @@ pub fn start_app(setup: Setup, options: Options) anyerror!void {
const app = try world.set_get_resource(App{
.events = &events,
.target_frame_time = 1.0 / @as(f64, @floatFromInt(options.tick_rate)),
.elapsed_time = 0,
.is_running = true,
});
@ -85,7 +86,8 @@ pub fn start_app(setup: Setup, options: Options) anyerror!void {
try setup(&world, events);
try world.run_event(events.load);
var ticks_previous = std.time.milliTimestamp();
const ticks_initial = std.time.milliTimestamp();
var ticks_previous = ticks_initial;
var accumulated_time = @as(f64, 0);
while (app.is_running) {
@ -93,6 +95,7 @@ pub fn start_app(setup: Setup, options: Options) anyerror!void {
const milliseconds_per_second = 1000.0;
const delta_time = @as(f64, @floatFromInt(ticks_current - ticks_previous)) / milliseconds_per_second;
app.elapsed_time = @as(f64, @floatFromInt(ticks_current - ticks_initial)) / milliseconds_per_second;
ticks_previous = ticks_current;
accumulated_time += delta_time;