Skip to content

Instantly share code, notes, and snippets.

@UberMouse
Last active January 17, 2025 02:44
Show Gist options
  • Save UberMouse/a79468eaa3ec2dd5849f9f31679e2d45 to your computer and use it in GitHub Desktop.
Save UberMouse/a79468eaa3ec2dd5849f9f31679e2d45 to your computer and use it in GitHub Desktop.
Two WebGL stress tests to test context losing behaviour. One using maplibre, one using raw WebGL. Both have a toggleable mode to properly dispose of webgl contexts or not. When disposed an unlimited number can be created, without disposing the limit is different per browser (16 for Chromium)
<!DOCTYPE html>
<html>
<head>
<title>MapLibre GL JS WebGL Context Stress Test</title>
<script src="https://unpkg.com/[email protected]/dist/maplibre-gl.js"></script>
<link href="https://unpkg.com/[email protected]/dist/maplibre-gl.css" rel="stylesheet" />
<style>
body {
margin: 0;
padding: 0;
}
.map-container {
width: 300px;
height: 200px;
margin: 10px;
display: inline-block;
position: relative;
border: 1px solid #ccc;
}
.main-viewer {
position: fixed;
top: 0;
left: 0;
width: 400px;
height: 300px;
border: 2px solid #ff4444;
z-index: 900;
background: #fff;
}
.main-viewer::before {
content: 'Main Viewer';
position: absolute;
top: -20px;
left: 0;
background: #ff4444;
color: white;
padding: 2px 8px;
font-size: 12px;
border-radius: 3px 3px 0 0;
}
#controls {
position: fixed;
top: 10px;
right: 10px;
background: white;
padding: 15px;
border: 1px solid #ccc;
border-radius: 4px;
box-shadow: 0 2px 6px rgba(0,0,0,0.3);
z-index: 1000;
}
#controls button {
display: block;
width: 100%;
margin: 5px 0;
padding: 8px 12px;
background: #4a8fff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
#controls button:hover {
background: #357abd;
}
#controls .settings {
margin: 10px 0;
padding: 10px;
background: #f5f5f5;
border-radius: 4px;
}
#controls .settings label {
display: block;
margin: 5px 0;
}
#status {
margin-top: 10px;
padding: 8px;
background: #f5f5f5;
border-radius: 4px;
text-align: center;
font-weight: bold;
}
#map-container {
position: absolute;
top: 0;
left: 420px; /* Space for main viewer */
right: 0;
bottom: 0;
overflow: auto;
padding: 10px;
}
#controls .metrics {
margin-top: 10px;
padding: 8px;
background: #e9f5ff;
border-radius: 4px;
font-family: monospace;
font-size: 12px;
}
#controls .metrics div {
margin: 4px 0;
}
#controls .metrics .main-viewer-active {
color: #ff4444;
border-bottom: 1px solid #ffdddd;
padding-bottom: 4px;
margin-bottom: 8px;
}
#controls .settings select {
width: 100%;
margin: 5px 0;
padding: 4px;
border-radius: 4px;
border: 1px solid #ccc;
}
#controls .metrics .cleanup-mode {
color: #0066cc;
border-bottom: 1px solid #cce5ff;
padding-bottom: 4px;
margin: 8px 0;
}
</style>
</head>
<body>
<div id="controls">
<div class="settings">
<label>
<input type="checkbox" id="cleanupEnabled" onchange="updateCleanupMode()"> Enable map.remove() cleanup
</label>
<label>
Max Maps:
<input type="number" id="maxMaps" value="8" min="1" max="100" style="width: 60px">
</label>
</div>
<button onclick="addMap()">Add Map</button>
<button onclick="removeAllMaps()">Remove All Maps</button>
<button onclick="stressTest()">Start Stress Test</button>
<button onclick="stopStressTest()">Stop Stress Test</button>
<div id="status">Maps: 0</div>
<div class="metrics">
<div class="main-viewer-active">Main Viewer Active: Yes</div>
<div class="cleanup-mode">Cleanup: <span id="currentMode">Disabled</span></div>
<div>Total Created: <span id="totalCreated">0</span></div>
<div>Total Destroyed: <span id="totalDestroyed">0</span></div>
<div>Current Active: <span id="currentActive">0</span></div>
<div>Test Maps Active: <span id="testMapsActive">0</span></div>
<div>GL Contexts Leaked: <span id="leakedContexts">0</span></div>
</div>
</div>
<div id="main-viewer" class="main-viewer"></div>
<div id="map-container"></div>
<script>
let mapCount = 0;
let maps = [];
let stressTestInterval = null;
let cleanupEnabled = false;
let mainViewerMap = null;
let contextMetrics = {
created: 0,
destroyed: 0,
leaked: 0
};
const style = {
version: 8,
sources: {
'raster-tiles': {
type: 'raster',
tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
tileSize: 256,
attribution: '&copy; OpenStreetMap Contributors'
}
},
layers: [{
id: 'simple-tiles',
type: 'raster',
source: 'raster-tiles',
minzoom: 0,
maxzoom: 22
}]
};
function updateCleanupMode() {
cleanupEnabled = document.getElementById('cleanupEnabled').checked;
document.getElementById('currentMode').textContent = cleanupEnabled ? 'Enabled' : 'Disabled';
updateMetrics();
}
function updateMetrics() {
document.getElementById('totalCreated').textContent = contextMetrics.created;
document.getElementById('totalDestroyed').textContent = contextMetrics.destroyed;
document.getElementById('currentActive').textContent = contextMetrics.created - contextMetrics.destroyed;
document.getElementById('testMapsActive').textContent = maps.length;
document.getElementById('leakedContexts').textContent = contextMetrics.leaked;
}
function getMaxMaps() {
return parseInt(document.getElementById('maxMaps').value) || 8;
}
function removeOldestMap() {
if (maps.length > 0) {
const oldestMap = maps.shift();
if (cleanupEnabled) {
oldestMap.map.remove();
oldestMap.container.remove();
contextMetrics.destroyed++;
} else {
oldestMap.container.remove();
contextMetrics.leaked++;
}
updateStatus();
updateMetrics();
}
}
function createMainViewer() {
const container = document.getElementById('main-viewer');
mainViewerMap = new maplibregl.Map({
container: container,
style: style,
center: [-74.5, 40],
zoom: 9
});
contextMetrics.created++;
updateMetrics();
}
function createMapContainer() {
const container = document.createElement('div');
container.className = 'map-container';
document.getElementById('map-container').appendChild(container);
return container;
}
function addMap() {
try {
if (cleanupEnabled && maps.length >= getMaxMaps()) {
removeOldestMap();
}
const container = createMapContainer();
const map = new maplibregl.Map({
container: container,
style: style,
center: [-74.5, 40],
zoom: 9
});
maps.push({
container,
map,
id: mapCount++
});
contextMetrics.created++;
updateStatus();
updateMetrics();
} catch (e) {
console.error('Failed to create map:', e);
alert(`Failed to create map: ${e.message}`);
}
}
function removeAllMaps() {
maps.forEach(map => {
if (cleanupEnabled) {
map.map.remove();
contextMetrics.destroyed++;
} else {
map.container.remove();
contextMetrics.leaked++;
}
});
maps = [];
updateStatus();
updateMetrics();
}
function updateStatus() {
const maxMaps = getMaxMaps();
const status = cleanupEnabled ?
`Maps: ${maps.length} (Max: ${maxMaps})` :
`Maps: ${maps.length}`;
document.getElementById('status').textContent = status;
}
let stressTestRunning = false;
function stressTest() {
if (stressTestRunning) return;
stressTestRunning = true;
stressTestInterval = setInterval(() => {
try {
addMap();
} catch (e) {
console.error('Stress test failed:', e);
stopStressTest();
alert(`Stress test failed at ${maps.length} maps: ${e.message}`);
}
}, 500); // Create a new map every 500ms
}
function stopStressTest() {
if (stressTestInterval) {
clearInterval(stressTestInterval);
stressTestInterval = null;
}
stressTestRunning = false;
}
// Initialize the main viewer on page load
window.addEventListener('load', createMainViewer);
// Initialize cleanup mode display
updateCleanupMode();
</script>
</body>
</html>
</html>
<!DOCTYPE html>
<html>
<head>
<title>WebGL Context Stress Test</title>
<style>
body {
margin: 0;
padding: 0;
}
.map-container {
width: 300px;
height: 200px;
margin: 10px;
display: inline-block;
position: relative;
border: 1px solid #ccc;
}
.main-viewer {
position: fixed;
top: 0;
left: 0;
width: 400px;
height: 300px;
border: 2px solid #ff4444;
z-index: 900;
background: #fff;
}
.main-viewer::before {
content: 'Main Viewer';
position: absolute;
top: -20px;
left: 0;
background: #ff4444;
color: white;
padding: 2px 8px;
font-size: 12px;
border-radius: 3px 3px 0 0;
}
.maplibregl-map {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
}
.maplibregl-canvas-container {
width: 100%;
height: 100%;
}
.maplibregl-canvas {
width: 100%;
height: 100%;
}
#controls {
position: fixed;
top: 10px;
right: 10px;
background: white;
padding: 15px;
border: 1px solid #ccc;
border-radius: 4px;
box-shadow: 0 2px 6px rgba(0,0,0,0.3);
z-index: 1000;
}
#controls button {
display: block;
width: 100%;
margin: 5px 0;
padding: 8px 12px;
background: #4a8fff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
#controls button:hover {
background: #357abd;
}
#controls .settings {
margin: 10px 0;
padding: 10px;
background: #f5f5f5;
border-radius: 4px;
}
#controls .settings label {
display: block;
margin: 5px 0;
}
#status {
margin-top: 10px;
padding: 8px;
background: #f5f5f5;
border-radius: 4px;
text-align: center;
font-weight: bold;
}
#map-container {
position: absolute;
top: 0;
left: 420px; /* Space for main viewer */
right: 0;
bottom: 0;
overflow: auto;
padding: 10px;
}
#controls .metrics {
margin-top: 10px;
padding: 8px;
background: #e9f5ff;
border-radius: 4px;
font-family: monospace;
font-size: 12px;
}
#controls .metrics div {
margin: 4px 0;
}
#controls .metrics .main-viewer-active {
color: #ff4444;
border-bottom: 1px solid #ffdddd;
padding-bottom: 4px;
margin-bottom: 8px;
}
#controls .settings select {
width: 100%;
margin: 5px 0;
padding: 4px;
border-radius: 4px;
border: 1px solid #ccc;
}
#controls .metrics .cleanup-mode {
color: #0066cc;
border-bottom: 1px solid #cce5ff;
padding-bottom: 4px;
margin: 8px 0;
}
</style>
</head>
<body>
<div id="controls">
<div class="settings">
<label>
Cleanup Mode:
<select id="cleanupMode" onchange="updateCleanupMode()">
<option value="none">No Cleanup (Accumulate)</option>
<option value="full">Full Cleanup (GL + DOM)</option>
<option value="dom">DOM-only Cleanup (Leak GL)</option>
</select>
</label>
<label>
Max Contexts:
<input type="number" id="maxContexts" value="8" min="1" max="100" style="width: 60px">
</label>
</div>
<button onclick="addMap()">Add Map</button>
<button onclick="removeAllMaps()">Remove All Maps</button>
<button onclick="stressTest()">Start Stress Test</button>
<button onclick="stopStressTest()">Stop Stress Test</button>
<div id="status">Maps: 0</div>
<div class="metrics">
<div class="main-viewer-active">Main Viewer Active: Yes</div>
<div class="cleanup-mode">Cleanup Mode: <span id="currentMode">No Cleanup</span></div>
<div>Total Created: <span id="totalCreated">0</span></div>
<div>Total Destroyed: <span id="totalDestroyed">0</span></div>
<div>Current Active: <span id="currentActive">0</span></div>
<div>Test Maps Active: <span id="testMapsActive">0</span></div>
<div>GL Contexts Leaked: <span id="leakedContexts">0</span></div>
</div>
</div>
<div id="main-viewer" class="main-viewer"></div>
<div id="map-container"></div>
<script>
let mapCount = 0;
let maps = [];
let stressTestInterval = null;
let cleanupMode = 'none';
let mainViewerContext = null;
let contextMetrics = {
created: 0,
destroyed: 0,
leaked: 0
};
function updateCleanupMode() {
cleanupMode = document.getElementById('cleanupMode').value;
document.getElementById('currentMode').textContent = {
'none': 'No Cleanup',
'full': 'Full Cleanup',
'dom': 'DOM-only (Leaking GL)'
}[cleanupMode];
updateMetrics();
}
function updateMetrics() {
document.getElementById('totalCreated').textContent = contextMetrics.created;
document.getElementById('totalDestroyed').textContent = contextMetrics.destroyed;
document.getElementById('currentActive').textContent = contextMetrics.created - contextMetrics.destroyed;
document.getElementById('testMapsActive').textContent = maps.length;
document.getElementById('leakedContexts').textContent = contextMetrics.leaked;
}
function getMaxContexts() {
return parseInt(document.getElementById('maxContexts').value) || 8;
}
function removeOldestMap() {
if (maps.length > 0) {
const oldestMap = maps.shift();
if (cleanupMode === 'full') {
// Full cleanup - both GL context and DOM
oldestMap.context.destroy();
} else if (cleanupMode === 'dom') {
// DOM-only cleanup - intentionally leak the GL context
contextMetrics.leaked++;
}
// Always remove the DOM elements
oldestMap.container.remove();
updateStatus();
}
}
// Create the main viewer map that persists throughout the test
function createMainViewer() {
const container = document.getElementById('main-viewer');
const mapDiv = document.createElement('div');
mapDiv.className = 'maplibregl-map';
const canvasContainer = document.createElement('div');
canvasContainer.className = 'maplibregl-canvas-container';
const canvas = document.createElement('canvas');
canvas.className = 'maplibregl-canvas';
canvasContainer.appendChild(canvas);
mapDiv.appendChild(canvasContainer);
container.appendChild(mapDiv);
mainViewerContext = new MockMaplibreContext(canvas);
// Start a render loop for the main viewer
const gl = mainViewerContext.gl;
function render() {
if (!gl) return;
// Use a consistent color for the main viewer
gl.clearColor(0.1, 0.2, 0.3, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT);
requestAnimationFrame(render);
}
render();
}
// Replicating MapLibre's WebGL context creation
class MockMaplibreContext {
constructor(canvas, options = {}) {
this.canvas = canvas;
this.attributes = {
alpha: true,
stencil: true,
depth: true,
failIfMajorPerformanceCaveat: options.failIfMajorPerformanceCaveat || false,
preserveDrawingBuffer: options.preserveDrawingBuffer || false,
antialias: options.antialias || false
};
// Try WebGL2 first, then fallback to WebGL1
this.gl = this.initWebGL();
if (!this.gl) {
throw new Error('Failed to initialize WebGL');
}
// Initialize extensions like MapLibre does
this.initExtensions();
// Track context creation
contextMetrics.created++;
updateMetrics();
}
initWebGL() {
try {
return (
this.canvas.getContext('webgl2', this.attributes) ||
this.canvas.getContext('webgl', this.attributes) ||
this.canvas.getContext('experimental-webgl', this.attributes)
);
} catch (e) {
console.error('WebGL context creation failed:', e);
return null;
}
}
initExtensions() {
if (!this.gl) return;
// Similar to MapLibre's extension initialization
this.extTextureFilterAnisotropic = (
this.gl.getExtension('EXT_texture_filter_anisotropic') ||
this.gl.getExtension('MOZ_EXT_texture_filter_anisotropic') ||
this.gl.getExtension('WEBKIT_EXT_texture_filter_anisotropic')
);
// Add more extensions as needed
if (this.isWebGL2()) {
this.gl.getExtension('EXT_color_buffer_float');
} else {
this.gl.getExtension('OES_texture_half_float');
this.gl.getExtension('OES_texture_half_float_linear');
this.gl.getExtension('OES_texture_float');
this.gl.getExtension('OES_texture_float_linear');
}
}
isWebGL2() {
return this.gl instanceof WebGL2RenderingContext;
}
destroy() {
if (this.gl) {
// Get all textures
const textures = this.gl.getParameter(this.gl.TEXTURE_BINDING_2D);
if (textures) {
this.gl.deleteTexture(textures);
}
// Get all buffers
const buffers = this.gl.getParameter(this.gl.ARRAY_BUFFER_BINDING);
if (buffers) {
this.gl.deleteBuffer(buffers);
}
// Get all framebuffers
const framebuffers = this.gl.getParameter(this.gl.FRAMEBUFFER_BINDING);
if (framebuffers) {
this.gl.deleteFramebuffer(framebuffers);
}
// Get all renderbuffers
const renderbuffers = this.gl.getParameter(this.gl.RENDERBUFFER_BINDING);
if (renderbuffers) {
this.gl.deleteRenderbuffer(renderbuffers);
}
// Lose context if possible
const ext = this.gl.getExtension('WEBGL_lose_context');
if (ext) {
ext.loseContext();
}
// Track context destruction
contextMetrics.destroyed++;
updateMetrics();
}
}
}
function createMapContainer() {
const container = document.createElement('div');
container.className = 'map-container';
const mapDiv = document.createElement('div');
mapDiv.className = 'maplibregl-map';
const canvasContainer = document.createElement('div');
canvasContainer.className = 'maplibregl-canvas-container';
const canvas = document.createElement('canvas');
canvas.className = 'maplibregl-canvas';
canvasContainer.appendChild(canvas);
mapDiv.appendChild(canvasContainer);
container.appendChild(mapDiv);
document.getElementById('map-container').appendChild(container);
return { container, canvas };
}
function addMap() {
try {
if (cleanupMode !== 'none' && maps.length >= getMaxContexts()) {
removeOldestMap();
}
const { container, canvas } = createMapContainer();
const context = new MockMaplibreContext(canvas);
maps.push({
container,
context,
id: mapCount++
});
updateStatus();
// Start a simple render loop
const gl = context.gl;
function render() {
if (!gl) return;
// Clear with a random color to show it's working
gl.clearColor(Math.random(), Math.random(), Math.random(), 1.0);
gl.clear(gl.COLOR_BUFFER_BIT);
requestAnimationFrame(render);
}
render();
} catch (e) {
console.error('Failed to create map:', e);
alert(`Failed to create map: ${e.message}`);
}
}
function removeAllMaps() {
maps.forEach(map => {
if (cleanupMode === 'full') {
map.context.destroy();
} else if (cleanupMode === 'dom') {
contextMetrics.leaked += 1;
}
map.container.remove();
});
maps = [];
updateStatus();
updateMetrics();
}
function updateStatus() {
const maxContexts = getMaxContexts();
const status = cleanupMode !== 'none' ?
`Maps: ${maps.length} (Max: ${maxContexts})` :
`Maps: ${maps.length}`;
document.getElementById('status').textContent = status;
}
let stressTestRunning = false;
function stressTest() {
if (stressTestRunning) return;
stressTestRunning = true;
stressTestInterval = setInterval(() => {
try {
addMap();
} catch (e) {
console.error('Stress test failed:', e);
stopStressTest();
alert(`Stress test failed at ${maps.length} maps: ${e.message}`);
}
}, 500); // Create a new map every 500ms
}
function stopStressTest() {
if (stressTestInterval) {
clearInterval(stressTestInterval);
stressTestInterval = null;
}
stressTestRunning = false;
}
// Initialize the main viewer on page load
window.addEventListener('load', createMainViewer);
// Initialize cleanup mode display
updateCleanupMode();
</script>
</body>
</html>
</html> html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment