Profiling and Tracing
Route internal renderer timing into your profiler or inspect trace files to diagnose frame drops.
You can stream the renderer's internal timing into your engine's profiler or write a trace file to track down slow frames.
The timing breaks down each phase of a frame (layout, painting, and JavaScript execution) alongside your own code in your profiler. Having that breakdown in your existing tools shows you which part of the UI pushed the frame over budget.
The Profiler interface is available in the Pro edition and higher, while render traces need the Enterprise edition (see Editions and Feature Macros). Both interfaces are compiled out of lower editions behind UL_HAS(PROFILER) and UL_HAS(RENDER_TRACE).
📘 Automatic Traces in AppCore
In AppCore apps, setting
Settings::enable_profilertotruecausesApp::Create()to write a timestamped.perfetto-tracefile into aprofiler_tracesfolder inside your diagnostics directory (see App Lifecycle and Settings). That directory sits next to your log file and is determined bySettings::developer_nameandapp_name, never by anyConfig::cache_pathyou set.App::is_profiler_active()tells you if a trace is running, andApp::profiler_trace_path()returns the path to the file.When you assign a profiler through
Platform::set_profiler()before callingApp::Create(), the app keeps your profiler and ignores the setting with a log warning. If writing the trace file fails, the error appears in your log andApp::is_profiler_active()returnsfalse.
Writing a Trace File
If your app creates the Renderer directly, you can stream a Perfetto trace to disk using the library's built-in file writer.
Creating the Trace Writer
Calling CreateProfilerPerfetto(path) returns a Profiler instance that writes a binary trace while keeping memory usage bounded. Partial traces stay valid, so you can still open the file at https://ui.perfetto.dev if your app terminates early.
Make sure the folder for the trace file already exists before creating the writer— if the file cannot be opened, the profiler leaves no message and records nothing.
CreateProfilerPerfetto() returns nullptr when the trace writer is not available on your platform or edition. You should check the pointer before passing it to Platform::instance().set_profiler() (see Setting Up the Platform).
Lifetime and Teardown
Your profiler instance must outlive the Renderer. When shutting down, destroy the Renderer before clearing the platform slot with nullptr, then free the writer by calling DestroyProfilerPerfetto() (which accepts nullptr safely).
Guard all profiler calls with #if UL_HAS(PROFILER) so your code compiles on editions without profiler support:
#include <Ultralight/Ultralight.h>
using namespace ultralight;
RefPtr<Renderer> renderer;
#if UL_HAS(PROFILER)
Profiler* profiler = nullptr;
void Init() {
///
/// Create the trace writer and install it before creating the Renderer.
/// (nullptr means tracing isn't available for this platform or edition.)
///
profiler = CreateProfilerPerfetto("trace.perfetto-trace");
if (profiler)
Platform::instance().set_profiler(profiler);
// Pseudo-code, set up the rest of the Platform (FileSystem, FontLoader) here.
renderer = Renderer::Create();
}
void Shutdown() {
///
/// Release the Renderer first-- the profiler must outlive it.
///
renderer = nullptr;
Platform::instance().set_profiler(nullptr);
DestroyProfilerPerfetto(profiler);
}
#endif // UL_HAS(PROFILER)
Forwarding to Your Own Profiler
You can route the renderer's timing data into tools like Tracy, Optick, or Unreal Insights by implementing your own Profiler.
Installing a Custom Profiler
Subclass Profiler and provide definitions for its pure virtual methods.
Pass your instance to Platform::instance().set_profiler() before creating the Renderer or App. Your application keeps ownership of the profiler, and the object must outlive the Renderer or App.
🚧 Profiler callbacks must be thread-safe
The library can invoke any
Profilercallback concurrently from arbitrary threads. Your implementation must guard shared state or use thread-safe primitives.
Scopes
BeginScope(name) and EndScope() delimit a named time interval on the calling thread. The library always balances these calls, so your profiler can track nested regions with a per-thread stack.
The name argument is a static string literal that lives as long as the library, allowing zone-based tools to retain the pointer directly without making a copy.
Scope names use dotted identifiers so profilers can filter and group timings by area.
| Prefix | Subsystem |
|---|---|
Ultralight.Update, Ultralight.Render, Ultralight.ViewPaint |
Engine updates, rendering, and View painting |
WebCore.Layout, WebCore.Paint, WebCore.EvaluateScript |
DOM layout, visual painting, and script evaluation |
JSC.CallFunction |
JavaScript function execution |
AppCore.Frame |
AppCore window frame updates |
Frames
The renderer invokes BeginFrame() and EndFrame() around each frame pass, and you should never invoke them yourself.
Both methods have empty default implementations. You can override them to track latency from dirty state to display and spot frame drops (see Updating and Rendering).
Events and Counters
EmitEvent() marks an instant on the timeline (such as the start of a navigation), and its detail string may be nullptr.
EmitCounter() receives periodic numeric values like memory or cache sizes, which profiling tools typically plot as continuous graphs.
These resource counters arrive every Config::resource_sample_interval milliseconds (500 by default). Passing 0 disables counter sampling entirely without altering scope or event callbacks (see Managing Memory).
Async Events
BeginEvent() and EndEvent() mark spans of work that can overlap or cross frame boundaries, such as page loads and asset fetches (the detail string on BeginEvent() may be nullptr).
Your implementation must return a non-zero identifier from BeginEvent(). The library hands that value back to EndEvent() when closing the operation, whereas returning 0 signals that no event was started.
Custom Profiler Example
Here is a complete custom Profiler that routes scopes, frames, timeline events, and numeric counters into an engine profiler:
#include <Ultralight/Ultralight.h>
#include <atomic>
using namespace ultralight;
#if UL_HAS(PROFILER)
class MyProfiler : public Profiler {
public:
///
/// Scopes are balanced and per-thread, a natural fit for a zone-based
/// profiler.
///
void BeginScope(const char* name) override {
// Pseudo-code, push a named zone on your engine's profiler here.
EngineProfilerPushZone(name);
}
void EndScope() override {
// Pseudo-code, pop the current zone here.
EngineProfilerPopZone();
}
///
/// The library brackets each rendering frame for us.
///
void BeginFrame(uint64_t frame_id) override {
// Pseudo-code, mark the start of a frame here.
EngineProfilerFrameStart(frame_id);
}
void EndFrame() override {
// Pseudo-code, mark the end of a frame here.
EngineProfilerFrameEnd();
}
void EmitEvent(const char* name, const char* detail) override {
// Pseudo-code, drop an instant marker here (detail may be nullptr).
EngineProfilerMarker(name, detail ? detail : "");
}
void EmitCounter(const char* name, int64_t value) override {
// Pseudo-code, plot a named value here.
EngineProfilerPlot(name, value);
}
///
/// Async events overlap and cross frames, so hand back an id we can match
/// later.
///
uint64_t BeginEvent(const char* name, const char* detail) override {
uint64_t id = next_event_id_++;
// Pseudo-code, begin an async span with this id here.
EngineProfilerBeginAsync(id, name);
return id;
}
void EndEvent(uint64_t id) override {
// Pseudo-code, end the async span with this id here.
EngineProfilerEndAsync(id);
}
private:
std::atomic<uint64_t> next_event_id_ { 1 };
};
MyProfiler my_profiler;
void InitProfiler() {
///
/// Sample resource counters every 250 ms (0 turns them off) as part of your
/// Config, then install our profiler before creating the Renderer. (It must
/// outlive the Renderer.)
///
Config config;
config.resource_sample_interval = 250;
Platform::instance().set_config(config);
Platform::instance().set_profiler(&my_profiler);
}
#endif // UL_HAS(PROFILER)
Render Traces
Enterprise edition licenses let you capture low-level rendering pipeline diagnostics in a Perfetto trace file.
Recording Diagnostics
Call Renderer::BeginRenderTrace(path, verbose) to start recording pipeline activity into a .perfetto-trace file you can view at https://ui.perfetto.dev. The method returns false if tracing is not supported or if a session is already in progress.
When you finish capturing frames, call Renderer::EndRenderTrace() to flush and close the output file. You can check Renderer::is_render_trace_active() whenever you need to confirm that a session is recording.
Trace Options and Concurrency
The verbose argument defaults to true, adding detailed stage annotations to the scope timings.
Render traces operate independently from the Profiler interface, so both can record at the same time:
#include <Ultralight/Ultralight.h>
using namespace ultralight;
#if UL_HAS(RENDER_TRACE)
void CaptureRenderTrace(Renderer* renderer) {
///
/// Record the rendering pipeline for a while, then flush and close the file.
///
renderer->BeginRenderTrace("render.perfetto-trace");
// Pseudo-code, run the frames you want to capture here.
renderer->EndRenderTrace();
}
#endif // UL_HAS(RENDER_TRACE)
Debugging Painting
Three settings help you investigate visual glitches when the problem is what the renderer draws rather than how long drawing takes.
Compositor Debug Information
Setting ViewConfig::enable_compositor_debug_info (false by default) draws outlines around compositor layers and tiles, together with a repaint count on every layer.
These overlays only appear when the compositor is enabled. You can toggle them during execution with View::set_compositor_debug_info_enabled().
Continuous Repaints
Setting Config::force_repaint (false by default) tells the renderer to repaint all Views on every frame even when nothing is dirty.
This is useful for debugging shaders and profiling steady-state rendering load. Set the option on Config before calling Renderer::Create() (see Creating the Renderer).
Full-Layer Repainting
Setting Config::paint_full_layers (false by default) redraws an entire layer whenever any region in it changes, bypassing dirty-rectangle clipping.
Use this to determine whether visual artifacts come from dirty-region tracking. Redrawing whole layers lowers performance, so keep it disabled during normal gameplay.
Once you identify where your frames spend their time, Optimizing Performance covers the controls available for speeding them up.