Compare commits

..

3 commits

Author SHA1 Message Date
bc7046180a 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 <noreply@anthropic.com>
2026-01-31 14:36:22 -05:00
1abec8f808 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 <noreply@anthropic.com>
2026-01-31 14:01:50 -05:00
0811b76946 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 <noreply@anthropic.com>
2026-01-31 13:54:48 -05:00
5 changed files with 369 additions and 77 deletions

View file

@ -269,6 +269,10 @@ if(EMSCRIPTEN)
--preload-file=${CMAKE_SOURCE_DIR}/src/scripts@/scripts --preload-file=${CMAKE_SOURCE_DIR}/src/scripts@/scripts
# Preload assets # Preload assets
--preload-file=${CMAKE_SOURCE_DIR}/assets@/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 # Add SDL2 options if using SDL2 backend
@ -288,6 +292,9 @@ if(EMSCRIPTEN)
target_link_options(mcrogueface PRIVATE ${EMSCRIPTEN_LINK_OPTIONS}) 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 # Set Python home for the embedded interpreter
target_compile_definitions(mcrogueface PRIVATE target_compile_definitions(mcrogueface PRIVATE
MCRF_WASM_PYTHON_HOME="/lib/python3.14" MCRF_WASM_PYTHON_HOME="/lib/python3.14"

69
src/emscripten_pre.js Normal file
View file

@ -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');
})();

View file

@ -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_VERTEX_SHADER = SPRITE_VERTEX_SHADER;
static const char* TEXT_FRAGMENT_SHADER = R"( static const char* TEXT_FRAGMENT_SHADER = R"(
#ifdef GL_ES #ifdef GL_ES
@ -107,9 +107,11 @@ varying vec2 v_texcoord;
uniform sampler2D u_texture; uniform sampler2D u_texture;
void main() { void main() {
// Text rendering: use texture alpha as coverage // Font atlas stores glyph alpha in texture alpha channel
float alpha = texture2D(u_texture, v_texcoord).a; // RGB is white (255,255,255), alpha varies per glyph pixel
gl_FragColor = vec4(v_color.rgb, v_color.a * alpha); 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);
} }
)"; )";
@ -1600,8 +1602,6 @@ void Sprite::draw(RenderTarget& target, RenderStates states) const {
// Static cache for font atlases - keyed by (font data pointer, character size) // Static cache for font atlases - keyed by (font data pointer, character size)
static std::map<std::pair<const Font*, unsigned int>, FontAtlas> s_fontAtlasCache; static std::map<std::pair<const Font*, unsigned int>, FontAtlas> s_fontAtlasCache;
static int textDebugCount = 0;
void Text::draw(RenderTarget& target, RenderStates states) const { void Text::draw(RenderTarget& target, RenderStates states) const {
if (!font_ || string_.empty() || !font_->isLoaded()) return; if (!font_ || string_.empty() || !font_->isLoaded()) return;
@ -1611,96 +1611,102 @@ void Text::draw(RenderTarget& target, RenderStates states) const {
if (it == s_fontAtlasCache.end()) { if (it == s_fontAtlasCache.end()) {
FontAtlas atlas; FontAtlas atlas;
if (!atlas.load(font_->getData(), font_->getDataSize(), static_cast<float>(characterSize_))) { if (!atlas.load(font_->getData(), font_->getDataSize(), static_cast<float>(characterSize_))) {
std::cerr << "Text::draw: Failed to create font atlas!" << std::endl;
return; // Failed to create atlas 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; it = s_fontAtlasCache.emplace(key, std::move(atlas)).first;
} }
const FontAtlas& atlas = it->second; 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(); Transform combined = states.transform * getTransform();
// Build vertex data for all glyphs // Helper lambda to build glyph geometry with a given color and offset
std::vector<float> vertices; auto buildGlyphs = [&](const Color& color, float offsetX, float offsetY,
std::vector<float> texcoords; std::vector<float>& verts, std::vector<float>& uvs, std::vector<float>& cols) {
std::vector<float> colors; float x = 0;
float y = atlas.getAscent();
float x = 0; float r = color.r / 255.0f;
float y = atlas.getAscent(); // Start at baseline float g = color.g / 255.0f;
float b = color.b / 255.0f;
float a = color.a / 255.0f;
float r = fillColor_.r / 255.0f; for (size_t i = 0; i < string_.size(); ++i) {
float g = fillColor_.g / 255.0f; char c = string_[i];
float b = fillColor_.b / 255.0f;
float a = fillColor_.a / 255.0f;
for (size_t i = 0; i < string_.size(); ++i) { if (c == '\n') {
char c = string_[i]; x = 0;
y += atlas.getLineHeight();
// Handle newlines
if (c == '\n') {
x = 0;
y += atlas.getLineHeight();
continue;
}
FontAtlas::GlyphInfo glyph;
if (!atlas.getGlyph(static_cast<uint32_t>(c), glyph)) {
// Try space for unknown glyphs
if (!atlas.getGlyph(' ', glyph)) {
continue; continue;
} }
FontAtlas::GlyphInfo glyph;
if (!atlas.getGlyph(static_cast<uint32_t>(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<float> 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 if (!outlineVerts.empty()) {
float x0 = x + glyph.xoff; SDL2Renderer::getInstance().drawTriangles(
float y0 = y + glyph.yoff; outlineVerts.data(), outlineVerts.size() / 2,
float x1 = x0 + glyph.width; outlineCols.data(), outlineUVs.data(),
float y1 = y0 + glyph.height; atlas.getTextureId(),
SDL2Renderer::ShaderType::Text
// 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});
} }
x += glyph.xadvance;
} }
if (!vertices.empty()) { // Draw fill text on top
// Debug: log first glyph's UVs std::vector<float> vertices, texcoords, colors;
static int uvDebugCount = 0; buildGlyphs(fillColor_, 0, 0, vertices, texcoords, colors);
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++;
}
// Use text shader (uses alpha from texture, not full RGBA multiply) if (!vertices.empty()) {
SDL2Renderer::getInstance().drawTriangles( SDL2Renderer::getInstance().drawTriangles(
vertices.data(), vertices.size() / 2, vertices.data(), vertices.size() / 2,
colors.data(), texcoords.data(), colors.data(), texcoords.data(),
@ -1862,6 +1868,46 @@ bool Shader::isAvailable() {
FontAtlas::FontAtlas() = default; 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<stbtt_fontinfo*>(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() { FontAtlas::~FontAtlas() {
if (textureId_) { if (textureId_) {
SDL2Renderer::getInstance().deleteTexture(textureId_); SDL2Renderer::getInstance().deleteTexture(textureId_);

View file

@ -154,6 +154,14 @@ public:
FontAtlas(); FontAtlas();
~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 // Load font data
bool load(const unsigned char* fontData, size_t dataSize, float fontSize); bool load(const unsigned char* fontData, size_t dataSize, float fontSize);

162
src/shell.html Normal file
View file

@ -0,0 +1,162 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>McRogueFace - WebGL</title>
<style>
body {
margin: 0;
padding: 20px;
background: #1a1a2e;
display: flex;
flex-direction: column;
align-items: center;
font-family: 'Segoe UI', system-ui, sans-serif;
color: #eee;
}
h1 {
margin-bottom: 10px;
color: #e94560;
}
#status {
margin-bottom: 10px;
color: #888;
}
.emscripten {
display: block;
margin: 0 auto;
}
#canvas {
border: 2px solid #e94560;
background: #000;
display: block;
/* Crisp pixel rendering - no smoothing/interpolation */
image-rendering: pixelated;
image-rendering: crisp-edges;
-ms-interpolation-mode: nearest-neighbor;
}
#output {
margin-top: 20px;
max-width: 1024px;
width: 100%;
max-height: 200px;
overflow-y: auto;
background: #0f0f23;
padding: 10px;
font-family: monospace;
font-size: 12px;
white-space: pre-wrap;
border: 1px solid #333;
}
.spinner {
margin: 20px;
width: 50px;
height: 50px;
border: 5px solid #333;
border-top-color: #e94560;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>
</head>
<body>
<h1>McRogueFace</h1>
<div id="status">Downloading...</div>
<div id="spinner" class="spinner"></div>
<canvas class="emscripten" id="canvas" oncontextmenu="event.preventDefault()" tabindex="-1"></canvas>
<div id="output"></div>
<script type='text/javascript'>
var statusElement = document.getElementById('status');
var spinnerElement = document.getElementById('spinner');
var outputElement = document.getElementById('output');
var canvasElement = document.getElementById('canvas');
// Pre-set canvas size
canvasElement.width = 1024;
canvasElement.height = 768;
var Module = {
print: (function() {
return function(text) {
if (arguments.length > 1) text = Array.prototype.slice.call(arguments).join(' ');
console.log(text);
outputElement.textContent += text + '\n';
outputElement.scrollTop = outputElement.scrollHeight;
};
})(),
printErr: function(text) {
if (arguments.length > 1) text = Array.prototype.slice.call(arguments).join(' ');
console.error(text);
outputElement.textContent += '[ERR] ' + text + '\n';
outputElement.scrollTop = outputElement.scrollHeight;
},
canvas: canvasElement,
setStatus: function(text) {
if (!Module.setStatus.last) Module.setStatus.last = { time: Date.now(), text: '' };
if (text === Module.setStatus.last.text) return;
var m = text.match(/([^(]+)\((\d+(\.\d+)?)\/(\d+)\)/);
var now = Date.now();
if (m && now - Module.setStatus.last.time < 30) return;
Module.setStatus.last.time = now;
Module.setStatus.last.text = text;
if (m) {
text = m[1];
}
statusElement.innerHTML = text;
},
totalDependencies: 0,
monitorRunDependencies: function(left) {
this.totalDependencies = Math.max(this.totalDependencies, left);
Module.setStatus(left ? 'Preparing... (' + (this.totalDependencies-left) + '/' + this.totalDependencies + ')' : 'All downloads complete.');
},
onRuntimeInitialized: function() {
console.log('Emscripten runtime initialized');
spinnerElement.style.display = 'none';
}
};
Module.setStatus('Downloading...');
window.onerror = function(event) {
Module.setStatus('Error! See console for details.');
spinnerElement.style.display = 'none';
};
// Stub for resolveGlobalSymbol - needed by Emscripten's promising main feature
if (typeof resolveGlobalSymbol === 'undefined') {
window.resolveGlobalSymbol = function(name, direct) {
return {
sym: Module['_' + name] || Module[name],
type: 'function'
};
};
}
// Prevent browser zoom on canvas (Ctrl+scroll causes Emscripten heap errors)
// Use capture phase to intercept BEFORE Emscripten's handlers
// Also stop propagation to prevent the event from reaching SDL
document.addEventListener('wheel', function(e) {
if (e.ctrlKey) {
e.preventDefault();
e.stopImmediatePropagation();
return false;
}
}, { passive: false, capture: true });
// Also prevent Ctrl+Plus/Minus zoom
document.addEventListener('keydown', function(e) {
if (e.ctrlKey && (e.key === '+' || e.key === '-' || e.key === '=' || e.key === '_')) {
e.preventDefault();
e.stopImmediatePropagation();
return false;
}
}, { capture: true });
</script>
{{{ SCRIPT }}}
</body>
</html>