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 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"

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_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);
}
)";
@ -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 std::map<std::pair<const Font*, unsigned int>, FontAtlas> s_fontAtlasCache;
static int textDebugCount = 0;
void Text::draw(RenderTarget& target, RenderStates states) const {
if (!font_ || string_.empty() || !font_->isLoaded()) return;
@ -1611,40 +1611,28 @@ void Text::draw(RenderTarget& target, RenderStates states) const {
if (it == s_fontAtlasCache.end()) {
FontAtlas atlas;
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
}
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<float> vertices;
std::vector<float> texcoords;
std::vector<float> colors;
// Helper lambda to build glyph geometry with a given color and offset
auto buildGlyphs = [&](const Color& color, float offsetX, float offsetY,
std::vector<float>& verts, std::vector<float>& uvs, std::vector<float>& cols) {
float x = 0;
float y = atlas.getAscent(); // Start at baseline
float y = atlas.getAscent();
float r = fillColor_.r / 255.0f;
float g = fillColor_.g / 255.0f;
float b = fillColor_.b / 255.0f;
float a = fillColor_.a / 255.0f;
float r = color.r / 255.0f;
float g = color.g / 255.0f;
float b = color.b / 255.0f;
float a = color.a / 255.0f;
for (size_t i = 0; i < string_.size(); ++i) {
char c = string_[i];
// Handle newlines
if (c == '\n') {
x = 0;
y += atlas.getLineHeight();
@ -1653,15 +1641,14 @@ void Text::draw(RenderTarget& target, RenderStates states) const {
FontAtlas::GlyphInfo glyph;
if (!atlas.getGlyph(static_cast<uint32_t>(c), glyph)) {
// Try space for unknown glyphs
if (!atlas.getGlyph(' ', glyph)) {
continue;
}
}
// Calculate quad corners in local space
float x0 = x + glyph.xoff;
float y0 = y + glyph.yoff;
// 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;
@ -1671,36 +1658,55 @@ void Text::draw(RenderTarget& target, RenderStates states) const {
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
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
});
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
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
});
// 6 vertices * 4 color components
for (int v = 0; v < 6; ++v) {
colors.insert(colors.end(), {r, g, b, a});
cols.insert(cols.end(), {r, g, b, a});
}
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 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);
}
// Use text shader (uses alpha from texture, not full RGBA multiply)
if (!outlineVerts.empty()) {
SDL2Renderer::getInstance().drawTriangles(
outlineVerts.data(), outlineVerts.size() / 2,
outlineCols.data(), outlineUVs.data(),
atlas.getTextureId(),
SDL2Renderer::ShaderType::Text
);
}
}
// Draw fill text on top
std::vector<float> vertices, texcoords, colors;
buildGlyphs(fillColor_, 0, 0, vertices, texcoords, colors);
if (!vertices.empty()) {
SDL2Renderer::getInstance().drawTriangles(
vertices.data(), vertices.size() / 2,
colors.data(), texcoords.data(),
@ -1862,6 +1868,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<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() {
if (textureId_) {
SDL2Renderer::getInstance().deleteTexture(textureId_);

View file

@ -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);

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>