floooh / sokol

minimal cross-platform standalone C headers
https://floooh.github.io/sokol-html5
zlib License
6.97k stars 489 forks source link

Issues rendering using sokol_gl #1120

Open leiradel opened 1 day ago

leiradel commented 1 day ago

Build

I have a Sokol.c file that implements all Sokol things that I use. I'm currently focusing on making it work on Windows, so please disregard any Android code.

#if defined(__ANDROID__)
#elif defined(_WIN32)
#else
#error Unsupported platform
#endif

#if defined(_WIN32)
#include <sokol_app.h>
#endif

#define SOKOL_IMPL
#include <sokol_gfx.h>
#include <util/sokol_gl.h>

#if defined(_WIN32)
#include <sokol_glue.h>
#endif

Initialization and Teardown

Those are called when the app starts, and before it exits.

void rm::renderer::init() {
    RM_INFO("Initializing renderer");

    {
        sg_desc desc = {};

#if defined(_WIN32)
        desc.environment = sglue_environment();
#elif defined(__ANDROID__)
        desc.environment.defaults.color_format = SG_PIXELFORMAT_BGRA8;
#endif

        desc.logger.func = logger;

        sg_setup(&desc);

        if (!sg_isvalid()) {
            RM_FATAL("Failed to setup Sokol GFX");
            return;
        }
    }

    {
        sgl_desc_t desc = {};
        desc.logger.func = logger;
        sgl_setup(&desc);
    }

    {
        sg_sampler_desc desc = {};

        desc.min_filter = desc.mag_filter = SG_FILTER_NEAREST;
        desc.wrap_u = desc.wrap_v = SG_WRAP_CLAMP_TO_EDGE;
        s_samplerNearest = sg_make_sampler(&desc);

        if (sg_query_sampler_state(s_samplerNearest) != SG_RESOURCESTATE_VALID) {
            RM_FATAL("Failed to create the nearest sampler");
        }
    }

    {
        sg_sampler_desc desc = {};

        desc.min_filter = desc.mag_filter = SG_FILTER_LINEAR;
        desc.wrap_u = desc.wrap_v = SG_WRAP_CLAMP_TO_EDGE;
        s_samplerLinear = sg_make_sampler(&desc);

        if (sg_query_sampler_state(s_samplerLinear) != SG_RESOURCESTATE_VALID) {
            RM_FATAL("Failed to create the linear sampler");
        }
    }

    {
        sgl_context_desc_t desc = {};

        desc.color_format = SG_PIXELFORMAT_RGBA8;
        desc.depth_format = SG_PIXELFORMAT_NONE;
        desc.sample_count = 1;

        s_offscreenContext = sgl_make_context(&desc);
    }
}

void rm::renderer::destroy() {
    sgl_destroy_context(s_offscreenContext);
    sg_destroy_sampler(s_samplerLinear);
    sg_destroy_sampler(s_samplerNearest);
    sgl_shutdown();
    sg_shutdown();
}

Render target

This is how I create a render target.

class SokolRenderTarget : public rm::renderer::RenderTarget {
public:
    SokolRenderTarget(long const width, long const height) : rm::renderer::RenderTarget(width, height) {
        sg_desc const sgdesc = sg_query_desc();

        {
            sg_image_desc desc = {};
            desc.render_target = true;
            desc.width         = width;
            desc.height        = height;

            _target = sg_make_image(&desc);

            if (sg_query_image_state(_target) != SG_RESOURCESTATE_VALID) {
                RM_FATAL("Failed to create frame buffer image");
                return;
            }

            RM_DEBUG("Created color attachment");
        }

        {
            sg_attachments_desc desc = {};
            desc.colors[0].image = _target;

            _attachments = sg_make_attachments(&desc);

            if (sg_query_attachments_state(_attachments) != SG_RESOURCESTATE_VALID) {
                RM_FATAL("Failed to create frame buffer attachments");
                return;
            }

            RM_DEBUG("Created the attachments");
        }
    }

    ~SokolRenderTarget() {
        sg_destroy_attachments(_attachments);
        sg_destroy_image(_target);
    }

    sg_image       _target;
    sg_attachments _attachments;
};

Passes

rm::renderer::start() is called whenever I start drawing to the default target or to a render target. In the case of the default target, the target argument will be null. rm::renderer::present() is called when rendering ends.

void rm::renderer::start(rm::renderer::RenderTarget const* target) {
    s_renderTarget = target;

    if (target == nullptr) {
        sgl_set_context(SGL_DEFAULT_CONTEXT);
        sgl_defaults();

        sg_pass pass = {};
        pass.action.colors[0].load_action = SG_LOADACTION_CLEAR;
        pass.action.colors[0].clear_value = { 0.0f, 0.0f, 0.0f, 1.0f };
        pass.swapchain = sglue_swapchain(); // Windows only?

        sg_begin_pass(&pass);
    }
    else {
        auto const t = (SokolRenderTarget const*)target;

        sgl_set_context(s_offscreenContext);
        sgl_defaults();

        sg_pass pass = {};
        pass.action.colors[0].load_action = SG_LOADACTION_CLEAR;
        pass.action.colors[0].clear_value = { 0.0f, 0.0f, 0.0f, 1.0f };
        pass.attachments = t->_attachments;

        sg_begin_pass(&pass);
    }
}

void rm::renderer::present() {
    if (s_renderTarget == nullptr) {
        sgl_context_draw(SGL_DEFAULT_CONTEXT);
    }
    else {
        sgl_context_draw(s_offscreenContext);
    }

    sg_end_pass();
}

Rendering

Actual rendering happens in Lua code.

local t = 0

rm.onDraw(function(dt, display)
    local quad = rm.createQuad()

    -- A: update the render target with the game framebuffer
    target:draw(function()
        quad:position(0, 0)
        quad:color(1, 1, 1, 1)
        quad:size(target:size())
        frontend:draw(dt, quad, 'nearest', 'none')
    end)

    -- draw to the screen
    display:draw(function()
        local width, height = display:size()

        -- B: draw the game using super-sampling
        quad:position(0, 0)
        quad:size(width, height)
        quad:color(rm.rgb(255, 0, 0))
        quad:angle(0)
        quad:draw(target, 'linear', 'none')

        -- C: draw a rotating quad
        quad:position(width // 4, height // 4)
        quad:center(quad:position())
        quad:size(width // 4, height // 4)
        quad:color(rm.rgb(255, 255, 255))
        quad:angle(t)
        quad:draw('none')
    end)

    t = t + dt / 1000000000
end)

So target:draw() will render to a render target, and will call rm::renderer::start() with it as the argument, and call rm::renderer::end() when the Lua function returns. display is a fake render target, an empty user data Lua object which has only the draw method, and that will call rm::renderer::start() with a null pointer, and rm::renderer::end() when the Lua function returns.

Quads are drawn with this code:

static void draw(rm::Quad const& quad, sg_image image, rm::renderer::Filter filter, rm::renderer::Post post) {
    sg_sampler sampler = (filter == rm::renderer::Filter::Linear) ? s_samplerLinear : s_samplerNearest;
    sgl_enable_texture();
    sgl_texture(image, sampler);
    sgl_ortho(0.0f, s_size.width(), s_size.height(), 0.0f, -1.0f, 1.0f);
    sgl_matrix_mode_modelview();

    sgl_begin_quads();

    {
        rm::Point       const& position = quad.position();
        rm::Point       const& center   = quad.center();
        rm::Vec2        const& scale    = quad.scale();
        float           const  angle    = -quad.angle();
        rm::Color       const& color    = quad.color();
        rm::Size<float> const& size     = quad.size();

        rm::Point const screencenter(position.x() + center.x(), position.y() + center.y());

        rm::renderer::resetTransforms();
        rm::renderer::scaleAt(scale.x(), scale.y(), center.x(), center.y());
        rm::renderer::rotateAt(angle, center.x(), center.y());

        rm::Point tl = quad.position();
        rm::Point tr(tl.x() + size.width(), tl.y());
        rm::Point bl(tl.x(), tl.y() + size.height());
        rm::Point br(tl.x() + size.width(), tl.y() + size.height());

        tl = rm::renderer::transform(tl);
        tr = rm::renderer::transform(tr);
        bl = rm::renderer::transform(bl);
        br = rm::renderer::transform(br);

        sgl_v2f_t2f_c4f(tl.x(), tl.y(), 0.0f, 0.0f, color.r(), color.g(), color.b(), color.a());
        sgl_v2f_t2f_c4f(tr.x(), tr.y(), 1.0f, 0.0f, color.r(), color.g(), color.b(), color.a());
        sgl_v2f_t2f_c4f(br.x(), br.y(), 1.0f, 1.0f, color.r(), color.g(), color.b(), color.a());
        sgl_v2f_t2f_c4f(bl.x(), bl.y(), 0.0f, 1.0f, color.r(), color.g(), color.b(), color.a());
    }

    sgl_end();
}

The transform code is mine, since I don't want to depend on sokol_gl because I intend to use this code on platforms where sokol_gfx is not supported, so I'm already making this part multi-platform.

The image argument is a texture, or {SG_INVALID_ID} to render a solid quad.

In the Lua code above, if I only draw the rotating quad by commenting the A and B sections of the code, it works and I can see the rotating quad.

If I draw it with a texture, it also works. If I also draw the render target to the display buffer by uncommenting B, the screen is always black, and the rotating quad disappears. Same thing if I change B to draw a solid quad, but the screen is always red.

If I draw to the render target by uncommenting A, I get this:

[ERROR] sokol_gfx.h:16783: VALIDATE_APIP_COLOR_FORMAT: sg_apply_pipeline: pipeline color attachment pixel format doesn't match pass color attachment pixel format
[FATAL] sokol_gfx.h:16168: VALIDATION_FAILED: validation layer checks failed

I'm using recent Sokol headers.

Thanks in advance for looking into this.

floooh commented 8 hours ago

First thing: That validation error can be fixed by forcing the same pixel format in your offscreen render target to the same value you use for the sgl context, e.g. since the sgl context is created with SG_PIXELFORMAT_RGBA8 you'll also need to use that for the render target texture, and it's best to also force the sample count to the same value.

If those values are not explicitly provided, sokol-gfx will use default values taken from sg_desc.environment which may not match the values you want for the offscreen render target:

            sg_image_desc desc = {};
            desc.render_target = true;
            desc.width         = width;
            desc.height        = height;
            desc.format = SG_PIXELFORMAT_RGBA8;
            desc.sample_count = 1;
            _target = sg_make_image(&desc);
floooh commented 8 hours ago

The other thing I would recommend (but which shouldn't cause problems one way or the other, it's IMHO just cleaner) is to move the sg_begin_pass() calls from rm::renderer::start() down into rm::renderer::present(), but only if you exclusively use sokol_gl.h for rendering.

That's because most sokol-gl calls will only record draw commands into memory buffers and don't need to happen inside an sg_begin/end_pass, and the sgl_draw*() functions need to be called inside a sokol-gfx rendering pass.

E.g. smth like this:

void rm::renderer::present() {
    sg_pass pass = {};
    pass.action.colors[0].load_action = SG_LOADACTION_CLEAR;
    pass.action.colors[0].clear_value = { 0.0f, 0.0f, 0.0f, 1.0f };
    if (s_renderTarget == nullptr) {
        pass.swapchain = sglue_swapchain(); // Windows only?
        sg_begin_pass(&pass);
        sgl_context_draw(SGL_DEFAULT_CONTEXT);
    }
    else {
        pass.attachments = s_renderTarget->_attachments;
        sg_begin_pass(&pass);
        sgl_context_draw(s_offscreenContext);
    }
    sg_end_pass();
}
floooh commented 8 hours ago

Another thing: do you actually call sg_commit() anywhere? But read on...:

rm::renderer::start() is called whenever I start drawing to the default target or to a render target.

Currently (with the sg_begin_pass inside the function) this would be problematic if you attempt this multiple times per frame, because you would clear the underlying rendertarget each time you call rm::renderer::start(). It's also good practice to only have a single begin/end pass for each unique 'render destination' in a frame.

But if you move the sg_begin_pass() call down into the present() function (so that rm::renderer::start() basically only switches the sgl-context, then it wouldn't be a problem because calling rm::renderer::start would only switch to a different sgl-context, and that would only affect the sgl draw commands that are recorded into memory (but don't call any sokol-gfx functions).

In that case you only need to guarantee that rm::renderer::present() is only called once per frame after all sgl rendering has concluded (think of the sgl functions as 'recording functions', not 'render function'). I think I would just rename rm::renderer::present() to rm::renderer::commit(), perform ALL sokol-gfx rendering in there and then only call that once at the end of a complete frame (and not once per 'target'), e.g. something like this:

void rm::renderer::commit() {

    // first render all sgl draw commands recorded into the 'offscreen' sgl context into the offscreen render target
    sg_pass offscreen_pass = {};
    offscreen_pass.action.colors[0].load_action = SG_LOADACTION_CLEAR;
    offscreen_pass.action.colors[0].clear_value = { 0.0f, 0.0f, 0.0f, 1.0f };
    offscreen_pass.swapchain = sglue_swapchain(); // Windows only?
    offscreen_pass.attachments = s_renderTarget->_attachments;
    sg_begin_pass(&offscreen_pass);
    sgl_context_draw(s_offscreenContext);
    sg_end_pass();

    // then render all sgl commands recorded into the 'default sgl context' into the display framebuffer
    sg_pass display_pass = {};
    display_pass.action.colors[0].load_action = SG_LOADACTION_CLEAR;
    display_pass.action.colors[0].clear_value = { 0.0f, 0.0f, 0.0f, 1.0f };
    display_pass.swapchain = sglue_swapchain(); // Windows only?
    sg_begin_pass(&display_pass);
    sgl_context_draw(SGL_DEFAULT_CONTEXT);
    sg_end_pass();

    // and finally call sg_commit()
    sg_commit();
}
floooh commented 8 hours ago

...I guess that's it for the first round of feedback, the only obvious problem I found was the pixel format mismatch between the render target image and the sgl context. But I would really recommend changing the 'frame structure' so that all sokol-gfx calls happen centralized in that rm::renderer::commit() function I outlined above :)

floooh commented 7 hours ago

PS: also see how I use that same recommended frame structure here in the sgl-context-sapp sample:

https://github.com/floooh/sokol-samples/blob/0502fa1fb840e502235518af94e7ab27b7a7adb3/sapp/sgl-context-sapp.c#L109-L137

...first I'm recording all sgl rendering into the 'offscreen context', then I record all sgl commands for the 'default context', and after that I 'play back' those recorded commands into sokol-gfx render passes.

leiradel commented 7 hours ago

Thanks! I'll try all those changes. Can I use the same s_offscreenContext to draw in all render targets?

leiradel commented 7 hours ago

Hm I think I need a

sg_begin_pass(&offscreen_pass);
sgl_context_draw(s_offscreenContext);
sgl_end();

per render target?

I could record all render targets used in the frame, call the three functions above to each one of them, then do the default pass. Does that sound right?