Last active
January 17, 2025 02:44
-
-
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)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!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: '© 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> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!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