From 0811b76946a493341fe8489c8811bdbaa4a5132c Mon Sep 17 00:00:00 2001 From: John McCardle Date: Sat, 31 Jan 2026 13:54:48 -0500 Subject: [PATCH 1/3] Fix FontAtlas texture deletion bug - text now renders in WebGL The FontAtlas class was missing move semantics, causing the GPU texture to be deleted immediately after creation. When FontAtlas was moved into the cache with std::move(), the default move constructor copied textureId_, then the original's destructor deleted the texture the cache was using. Added: - Move constructor that transfers ownership and clears source textureId_ - Move assignment operator with proper cleanup of existing resources - Deleted copy operations since GPU textures can't be shared Also cleaned up the text fragment shader to use proper alpha sampling. Co-Authored-By: Claude Opus 4.5 --- src/platform/SDL2Renderer.cpp | 50 ++++++++++++++++++++++++++++++++--- src/platform/SDL2Renderer.h | 8 ++++++ 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/src/platform/SDL2Renderer.cpp b/src/platform/SDL2Renderer.cpp index 24c1c68..9ab2bdc 100644 --- a/src/platform/SDL2Renderer.cpp +++ b/src/platform/SDL2Renderer.cpp @@ -96,7 +96,7 @@ void main() { } )"; -// Text shader is same as sprite for now +// Text shader - uses alpha from texture, color from vertex static const char* TEXT_VERTEX_SHADER = SPRITE_VERTEX_SHADER; static const char* TEXT_FRAGMENT_SHADER = R"( #ifdef GL_ES @@ -107,9 +107,11 @@ varying vec2 v_texcoord; uniform sampler2D u_texture; void main() { - // Text rendering: use texture alpha as coverage - float alpha = texture2D(u_texture, v_texcoord).a; - gl_FragColor = vec4(v_color.rgb, v_color.a * alpha); + // Font atlas stores glyph alpha in texture alpha channel + // RGB is white (255,255,255), alpha varies per glyph pixel + vec4 texSample = texture2D(u_texture, v_texcoord); + // Use vertex color for RGB, texture alpha for transparency + gl_FragColor = vec4(v_color.rgb, v_color.a * texSample.a); } )"; @@ -1862,6 +1864,46 @@ bool Shader::isAvailable() { FontAtlas::FontAtlas() = default; +FontAtlas::FontAtlas(FontAtlas&& other) noexcept + : textureId_(other.textureId_) + , fontSize_(other.fontSize_) + , ascent_(other.ascent_) + , descent_(other.descent_) + , lineHeight_(other.lineHeight_) + , glyphCache_(std::move(other.glyphCache_)) + , stbFontInfo_(other.stbFontInfo_) +{ + // Clear source to prevent double-deletion + other.textureId_ = 0; + other.stbFontInfo_ = nullptr; +} + +FontAtlas& FontAtlas::operator=(FontAtlas&& other) noexcept { + if (this != &other) { + // Clean up existing resources + if (textureId_) { + SDL2Renderer::getInstance().deleteTexture(textureId_); + } + if (stbFontInfo_) { + delete static_cast(stbFontInfo_); + } + + // Transfer ownership + textureId_ = other.textureId_; + fontSize_ = other.fontSize_; + ascent_ = other.ascent_; + descent_ = other.descent_; + lineHeight_ = other.lineHeight_; + glyphCache_ = std::move(other.glyphCache_); + stbFontInfo_ = other.stbFontInfo_; + + // Clear source to prevent double-deletion + other.textureId_ = 0; + other.stbFontInfo_ = nullptr; + } + return *this; +} + FontAtlas::~FontAtlas() { if (textureId_) { SDL2Renderer::getInstance().deleteTexture(textureId_); diff --git a/src/platform/SDL2Renderer.h b/src/platform/SDL2Renderer.h index 8246175..3a34238 100644 --- a/src/platform/SDL2Renderer.h +++ b/src/platform/SDL2Renderer.h @@ -154,6 +154,14 @@ public: FontAtlas(); ~FontAtlas(); + // Move semantics - transfer ownership of GPU resources + FontAtlas(FontAtlas&& other) noexcept; + FontAtlas& operator=(FontAtlas&& other) noexcept; + + // Disable copy - texture resources can't be shared + FontAtlas(const FontAtlas&) = delete; + FontAtlas& operator=(const FontAtlas&) = delete; + // Load font data bool load(const unsigned char* fontData, size_t dataSize, float fontSize); From 1abec8f808bc9019e661e555ce6f405c651b2a54 Mon Sep 17 00:00:00 2001 From: John McCardle Date: Sat, 31 Jan 2026 14:01:50 -0500 Subject: [PATCH 2/3] Add text outline support for SDL2/WebGL backend Implements SFML-compatible text outlines using multi-pass rendering: - Draws text 8 times at offset positions (N, NE, E, SE, S, SW, W, NW) with outline color, then draws fill text on top - Uses existing outlineThickness_ and outlineColor_ properties - Refactored Text::draw() with helper lambda for code reuse - Removed debug logging code Co-Authored-By: Claude Opus 4.5 --- src/platform/SDL2Renderer.cpp | 150 +++++++++++++++++----------------- 1 file changed, 77 insertions(+), 73 deletions(-) diff --git a/src/platform/SDL2Renderer.cpp b/src/platform/SDL2Renderer.cpp index 9ab2bdc..fa7bc01 100644 --- a/src/platform/SDL2Renderer.cpp +++ b/src/platform/SDL2Renderer.cpp @@ -1602,8 +1602,6 @@ void Sprite::draw(RenderTarget& target, RenderStates states) const { // Static cache for font atlases - keyed by (font data pointer, character size) static std::map, FontAtlas> s_fontAtlasCache; -static int textDebugCount = 0; - void Text::draw(RenderTarget& target, RenderStates states) const { if (!font_ || string_.empty() || !font_->isLoaded()) return; @@ -1613,96 +1611,102 @@ void Text::draw(RenderTarget& target, RenderStates states) const { if (it == s_fontAtlasCache.end()) { FontAtlas atlas; if (!atlas.load(font_->getData(), font_->getDataSize(), static_cast(characterSize_))) { - std::cerr << "Text::draw: Failed to create font atlas!" << std::endl; return; // Failed to create atlas } - std::cout << "Text::draw: Created font atlas, textureId=" << atlas.getTextureId() << std::endl; it = s_fontAtlasCache.emplace(key, std::move(atlas)).first; } const FontAtlas& atlas = it->second; - // Debug: log first few text draws - if (textDebugCount < 3) { - std::cout << "Text::draw: string='" << string_.substr(0, 20) << "' atlasTexId=" << atlas.getTextureId() - << " fillColor=(" << (int)fillColor_.r << "," << (int)fillColor_.g << "," << (int)fillColor_.b << ")" << std::endl; - textDebugCount++; - } - Transform combined = states.transform * getTransform(); - // Build vertex data for all glyphs - std::vector vertices; - std::vector texcoords; - std::vector colors; + // Helper lambda to build glyph geometry with a given color and offset + auto buildGlyphs = [&](const Color& color, float offsetX, float offsetY, + std::vector& verts, std::vector& uvs, std::vector& cols) { + float x = 0; + float y = atlas.getAscent(); - float x = 0; - float y = atlas.getAscent(); // Start at baseline + float r = color.r / 255.0f; + float g = color.g / 255.0f; + float b = color.b / 255.0f; + float a = color.a / 255.0f; - float r = fillColor_.r / 255.0f; - float g = fillColor_.g / 255.0f; - float b = fillColor_.b / 255.0f; - float a = fillColor_.a / 255.0f; + for (size_t i = 0; i < string_.size(); ++i) { + char c = string_[i]; - for (size_t i = 0; i < string_.size(); ++i) { - char c = string_[i]; - - // Handle newlines - if (c == '\n') { - x = 0; - y += atlas.getLineHeight(); - continue; - } - - FontAtlas::GlyphInfo glyph; - if (!atlas.getGlyph(static_cast(c), glyph)) { - // Try space for unknown glyphs - if (!atlas.getGlyph(' ', glyph)) { + if (c == '\n') { + x = 0; + y += atlas.getLineHeight(); continue; } + + FontAtlas::GlyphInfo glyph; + if (!atlas.getGlyph(static_cast(c), glyph)) { + if (!atlas.getGlyph(' ', glyph)) { + continue; + } + } + + // Calculate quad corners with offset + float x0 = x + glyph.xoff + offsetX; + float y0 = y + glyph.yoff + offsetY; + float x1 = x0 + glyph.width; + float y1 = y0 + glyph.height; + + // Transform to world space + Vector2f p0 = combined.transformPoint(x0, y0); + Vector2f p1 = combined.transformPoint(x1, y0); + Vector2f p2 = combined.transformPoint(x1, y1); + Vector2f p3 = combined.transformPoint(x0, y1); + + verts.insert(verts.end(), { + p0.x, p0.y, p1.x, p1.y, p2.x, p2.y, + p0.x, p0.y, p2.x, p2.y, p3.x, p3.y + }); + + uvs.insert(uvs.end(), { + glyph.u0, glyph.v0, glyph.u1, glyph.v0, glyph.u1, glyph.v1, + glyph.u0, glyph.v0, glyph.u1, glyph.v1, glyph.u0, glyph.v1 + }); + + for (int v = 0; v < 6; ++v) { + cols.insert(cols.end(), {r, g, b, a}); + } + + x += glyph.xadvance; + } + }; + + // Draw outline first (if any) + if (outlineThickness_ > 0 && outlineColor_.a > 0) { + std::vector outlineVerts, outlineUVs, outlineCols; + + // Draw at 8 positions around each glyph for outline effect + float t = outlineThickness_; + float offsets[][2] = { + {-t, -t}, {0, -t}, {t, -t}, + {-t, 0}, {t, 0}, + {-t, t}, {0, t}, {t, t} + }; + + for (auto& off : offsets) { + buildGlyphs(outlineColor_, off[0], off[1], outlineVerts, outlineUVs, outlineCols); } - // Calculate quad corners in local space - float x0 = x + glyph.xoff; - float y0 = y + glyph.yoff; - float x1 = x0 + glyph.width; - float y1 = y0 + glyph.height; - - // Transform to world space - Vector2f p0 = combined.transformPoint(x0, y0); - Vector2f p1 = combined.transformPoint(x1, y0); - Vector2f p2 = combined.transformPoint(x1, y1); - Vector2f p3 = combined.transformPoint(x0, y1); - - // Two triangles for quad - vertices.insert(vertices.end(), { - p0.x, p0.y, p1.x, p1.y, p2.x, p2.y, // Triangle 1 - p0.x, p0.y, p2.x, p2.y, p3.x, p3.y // Triangle 2 - }); - - texcoords.insert(texcoords.end(), { - glyph.u0, glyph.v0, glyph.u1, glyph.v0, glyph.u1, glyph.v1, // Triangle 1 - glyph.u0, glyph.v0, glyph.u1, glyph.v1, glyph.u0, glyph.v1 // Triangle 2 - }); - - // 6 vertices * 4 color components - for (int v = 0; v < 6; ++v) { - colors.insert(colors.end(), {r, g, b, a}); + if (!outlineVerts.empty()) { + SDL2Renderer::getInstance().drawTriangles( + outlineVerts.data(), outlineVerts.size() / 2, + outlineCols.data(), outlineUVs.data(), + atlas.getTextureId(), + SDL2Renderer::ShaderType::Text + ); } - - x += glyph.xadvance; } - if (!vertices.empty()) { - // Debug: log first glyph's UVs - static int uvDebugCount = 0; - if (uvDebugCount < 2 && texcoords.size() >= 12) { - std::cout << "Text UVs for first glyph: u0=" << texcoords[0] << " v0=" << texcoords[1] - << " u1=" << texcoords[4] << " v1=" << texcoords[5] - << " (vertexCount=" << (vertices.size()/2) << ")" << std::endl; - uvDebugCount++; - } + // Draw fill text on top + std::vector vertices, texcoords, colors; + buildGlyphs(fillColor_, 0, 0, vertices, texcoords, colors); - // Use text shader (uses alpha from texture, not full RGBA multiply) + if (!vertices.empty()) { SDL2Renderer::getInstance().drawTriangles( vertices.data(), vertices.size() / 2, colors.data(), texcoords.data(), From bc7046180ac869de90bcf9a6ccf0a072e4ce0f5a Mon Sep 17 00:00:00 2001 From: John McCardle Date: Sat, 31 Jan 2026 14:36:22 -0500 Subject: [PATCH 3/3] Add Emscripten shell and pre-JS for browser compatibility - src/shell.html: Custom HTML shell with crisp pixel CSS (image-rendering: pixelated) and zoom prevention on canvas - src/emscripten_pre.js: Patches browser quirks that cause crashes: - Intercepts resize/scroll events to ensure e.detail is always 0 - Wraps window properties (innerWidth, outerWidth, etc.) to always return integers, fixing browser zoom crashes - CMakeLists.txt: Output as .html, include shell and pre-js files The pre-JS fix addresses "attempt to write non-integer (undefined) into integer heap" errors that occurred when users zoomed the browser via Ctrl+scroll or browser menu. Co-Authored-By: Claude Opus 4.5 --- CMakeLists.txt | 7 ++ src/emscripten_pre.js | 69 ++++++++++++++++++ src/shell.html | 162 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 238 insertions(+) create mode 100644 src/emscripten_pre.js create mode 100644 src/shell.html diff --git a/CMakeLists.txt b/CMakeLists.txt index 265f910..49d3625 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -269,6 +269,10 @@ if(EMSCRIPTEN) --preload-file=${CMAKE_SOURCE_DIR}/src/scripts@/scripts # Preload assets --preload-file=${CMAKE_SOURCE_DIR}/assets@/assets + # Use custom HTML shell for crisp pixel rendering + --shell-file=${CMAKE_SOURCE_DIR}/src/shell.html + # Pre-JS to fix browser zoom causing undefined values in events + --pre-js=${CMAKE_SOURCE_DIR}/src/emscripten_pre.js ) # Add SDL2 options if using SDL2 backend @@ -288,6 +292,9 @@ if(EMSCRIPTEN) target_link_options(mcrogueface PRIVATE ${EMSCRIPTEN_LINK_OPTIONS}) + # Output as HTML to use the shell file + set_target_properties(mcrogueface PROPERTIES SUFFIX ".html") + # Set Python home for the embedded interpreter target_compile_definitions(mcrogueface PRIVATE MCRF_WASM_PYTHON_HOME="/lib/python3.14" diff --git a/src/emscripten_pre.js b/src/emscripten_pre.js new file mode 100644 index 0000000..a4b4108 --- /dev/null +++ b/src/emscripten_pre.js @@ -0,0 +1,69 @@ +// Pre-JS file for McRogueFace Emscripten build +// This runs BEFORE Emscripten's code, allowing us to patch browser quirks + +// Fix for browser zoom causing undefined values in resize events +// When browser zoom changes, some event/window properties can become undefined +// which causes Emscripten's HEAP32 writes to fail with assertion errors + +(function() { + 'use strict'; + + // Store original addEventListener + var originalAddEventListener = EventTarget.prototype.addEventListener; + + // Properties that Emscripten's uiEventHandlerFunc reads + // These need to be integers, not undefined + var windowIntegerProps = [ + 'innerWidth', 'innerHeight', + 'outerWidth', 'outerHeight', + 'pageXOffset', 'pageYOffset' + ]; + + // Ensure window properties return integers even during zoom transitions + windowIntegerProps.forEach(function(prop) { + var descriptor = Object.getOwnPropertyDescriptor(window, prop); + if (descriptor && descriptor.get) { + var originalGetter = descriptor.get; + Object.defineProperty(window, prop, { + get: function() { + var val = originalGetter.call(this); + // Return 0 if undefined/null, otherwise floor to integer + return (val === undefined || val === null) ? 0 : Math.floor(val); + }, + configurable: true + }); + } + }); + + // Wrap addEventListener to intercept resize/scroll events + EventTarget.prototype.addEventListener = function(type, listener, options) { + if (type === 'resize' || type === 'scroll') { + var wrappedListener = function(e) { + // Ensure e.detail is an integer + if (e.detail === undefined || e.detail === null) { + // Create a new event with detail = 0 + try { + Object.defineProperty(e, 'detail', { + value: 0, + writable: false + }); + } catch (ex) { + // If we can't modify, create a proxy event + e = new Proxy(e, { + get: function(target, prop) { + if (prop === 'detail') return 0; + var val = target[prop]; + return typeof val === 'function' ? val.bind(target) : val; + } + }); + } + } + return listener.call(this, e); + }; + return originalAddEventListener.call(this, type, wrappedListener, options); + } + return originalAddEventListener.call(this, type, listener, options); + }; + + console.log('McRogueFace: Emscripten pre-JS patches applied'); +})(); diff --git a/src/shell.html b/src/shell.html new file mode 100644 index 0000000..a1ab612 --- /dev/null +++ b/src/shell.html @@ -0,0 +1,162 @@ + + + + + + McRogueFace - WebGL + + + +

McRogueFace

+
Downloading...
+
+ +
+ + + {{{ SCRIPT }}} + +