diff --git a/emscripten/pages-index.html b/emscripten/pages-index.html
index e02d3a6728..42ac633144 100644
--- a/emscripten/pages-index.html
+++ b/emscripten/pages-index.html
@@ -56,6 +56,10 @@
/* The old direct launch and the ZIP path stay wired for the script but out of sight. */
#launch,#reload,label[for=ownershipFiles],#clearOwnership{display:none}
+ .quality{display:grid;grid-template-columns:repeat(3,1fr);gap:4px;margin-top:14px;padding:4px;border-radius:14px;background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.08)}
+ .quality button{padding:9px 6px;border-radius:11px;font-size:14px;font-weight:600;color:var(--ink);background:transparent;box-shadow:none}
+ .quality button[aria-pressed=true]{color:#06121b;background:linear-gradient(180deg,#6fd0ff,var(--blue));box-shadow:0 4px 14px rgba(47,180,255,.3)}
+ #qualityHint{margin-top:6px;font-size:12.5px;color:var(--dim)}
details{width:100%;color:var(--dim);font-size:13px}
summary{list-style:none;cursor:pointer;text-align:center;padding:6px;font-size:13px;color:var(--dim)}
summary::-webkit-details-marker{display:none}
@@ -103,6 +107,12 @@
Choose your Portal folder
Play
Turn your iPhone sideways. The game fills the screen.
+
+
+
+
+
+
@@ -231,15 +241,38 @@
Diagnostics
await navigator.serviceWorker.ready;
if(registration.waiting)registration.waiting.postMessage({type:'SKIP_WAITING'});
set('sw',true,navigator.serviceWorker.controller?'controlling':'registered');
- if(!navigator.serviceWorker.controller&&!sessionStorage.getItem('render360PagesReloaded')){
- sessionStorage.setItem('render360PagesReloaded','1');
- log('Service worker installed; reloading once for isolation.');
- location.reload();
- return new Promise(()=>{});
+ // The isolation headers only apply to pages the worker controls. After
+ // a deploy Safari can load the page before the new worker takes over,
+ // leaving it without SharedArrayBuffer, so reload whenever isolation is
+ // missing. The time guard stops a loop on browsers that can't isolate.
+ if(!navigator.serviceWorker.controller||!window.crossOriginIsolated){
+ let last=0;try{last=Number(sessionStorage.getItem('render360PagesReloadAt'))||0}catch(_){}
+ if(Date.now()-last>20000){
+ try{sessionStorage.setItem('render360PagesReloadAt',String(Date.now()))}catch(_){}
+ log('Service worker not isolating this page yet; reloading.');
+ location.reload();
+ return new Promise(()=>{});
+ }
}
}catch(error){set('sw',false,'registration failed');log('Service worker error:',error?.stack||String(error))}
}
+ const QUALITY_HINTS={
+ standard:'540p, lighter textures. Smoothest, and safest on older iPhones.',
+ hd:'720p, sharper textures. Uses more memory and battery.',
+ max:'Full screen resolution and full textures. May run out of memory on older iPhones.'
+ };
+ function setupQuality(){
+ let current='standard';
+ try{current=localStorage.getItem('render360Quality')||'standard'}catch(_){}
+ if(!QUALITY_HINTS[current])current='standard';
+ const buttons=[...document.querySelectorAll('.quality button')];
+ const show=()=>{for(const b of buttons)b.setAttribute('aria-pressed',String(b.dataset.quality===current));$('qualityHint').textContent=QUALITY_HINTS[current]};
+ for(const b of buttons)b.addEventListener('click',()=>{current=b.dataset.quality;try{localStorage.setItem('render360Quality',current)}catch(_){}show()});
+ show();
+ }
+ setupQuality();
+
function testSharedMemory(){
try{const memory=new WebAssembly.Memory({initial:1,maximum:1,shared:true});return !!memory.buffer&&Object.prototype.toString.call(memory.buffer).includes('SharedArrayBuffer')}
catch(error){log('Wasm shared-memory test:',String(error));return false}
diff --git a/emscripten/pre.js b/emscripten/pre.js
index 7d28bd51e0..957846bc62 100644
--- a/emscripten/pre.js
+++ b/emscripten/pre.js
@@ -307,6 +307,24 @@ Module['arguments'].push(
// engine's own on-screen touch controls are the only way to move or look.
// They default to off everywhere except Android. Window-only: this file also
// runs inside every pthread worker.
+// Graphics quality, picked on the start page or in the in-game menu.
+// standard: 540p, quarter-size textures (the safe default for older phones)
+// hd: 720p, half-size textures
+// max: the screen's full resolution, full-size textures (uses the most memory)
+const RENDER360_QUALITY = {
+ standard: { phoneHeight: 540, desktopHeight: 720, picmip: 2 },
+ hd: { phoneHeight: 720, desktopHeight: 1080, picmip: 1 },
+ max: { phoneHeight: 4096, desktopHeight: 4096, picmip: 0 }
+}
+let render360Quality = 'standard'
+if(render360IsWindow) {
+ try {
+ const saved = localStorage.getItem('render360Quality')
+ if(saved && RENDER360_QUALITY[saved]) render360Quality = saved
+ } catch(_) {}
+ Module.render360Quality = render360Quality
+}
+
if(render360IsWindow) {
let coarsePointer = false
try { coarsePointer = !!(window.matchMedia && window.matchMedia('(pointer: coarse)').matches) } catch(_) {}
@@ -320,7 +338,7 @@ if(render360IsWindow) {
// Quarter-size textures. On a 6 inch screen the difference is hard to
// see, and textures are the largest thing a map load adds to both the
// Wasm heap and GPU memory, which is where iOS kills the page.
- Module['arguments'].push('+mat_picmip', '2')
+ Module['arguments'].push('+mat_picmip', String(RENDER360_QUALITY[render360Quality].picmip))
// Cheaper rendering features for a phone GPU. Bump maps and specular
// stay on: turning them off makes Portal's materials ask for a
// $bumpmap texture that is not loaded (console errors).
@@ -348,7 +366,8 @@ if(render360IsWindow) {
} catch(_) {}
if(longSide > 0 && shortSide > 0) {
const dpr = Math.max(1, Number(window.devicePixelRatio || 1))
- const height = Math.max(360, Math.min(phone ? 540 : 720, Math.round(shortSide * dpr)))
+ const quality = RENDER360_QUALITY[render360Quality]
+ const height = Math.max(360, Math.min(phone ? quality.phoneHeight : quality.desktopHeight, Math.round(shortSide * dpr)))
const width = Math.round(height * longSide / shortSide / 2) * 2
Module.render360GameSize = { width, height }
Module['arguments'].push('-w', String(width), '-h', String(height))
diff --git a/emscripten/shell.html b/emscripten/shell.html
index 0736faef4c..e26baab0a0 100644
--- a/emscripten/shell.html
+++ b/emscripten/shell.html
@@ -227,6 +227,7 @@
Portal stopped
+
Shares the engine log with the developer
@@ -344,7 +345,30 @@
Portal stopped
}
// ---------------------------------------------------------- menu
+ var RENDER360_QUALITY_NAMES = { standard: 'Standard', hd: 'HD', max: 'Max' };
+ var RENDER360_QUALITY_ORDER = ['standard', 'hd', 'max'];
+ function render360SavedQuality() {
+ var q = 'standard';
+ try { q = localStorage.getItem('render360Quality') || 'standard'; } catch (_) {}
+ return RENDER360_QUALITY_NAMES[q] ? q : 'standard';
+ }
+ function render360ShowQuality() {
+ var row = document.getElementById('r360-quality-row');
+ if (!row) return;
+ var saved = render360SavedQuality();
+ var running = (window.Module && Module.render360Quality) || saved;
+ row.textContent = 'Graphics: ' + RENDER360_QUALITY_NAMES[saved] +
+ (saved !== running ? ' (next launch)' : '');
+ }
+ // Resolution and texture size are fixed when the engine starts, so a new
+ // choice applies the next time Portal is opened.
+ function render360CycleQuality() {
+ var next = RENDER360_QUALITY_ORDER[(RENDER360_QUALITY_ORDER.indexOf(render360SavedQuality()) + 1) % RENDER360_QUALITY_ORDER.length];
+ try { localStorage.setItem('render360Quality', next); } catch (_) {}
+ render360ShowQuality();
+ }
function render360OpenSheet(open) {
+ if (open) render360ShowQuality();
document.getElementById('r360-sheet').classList.toggle('r360-open', !!open);
}
@@ -410,7 +434,8 @@
' OffscreenCanvas=' + typeof OffscreenCanvas);
if (!window.crossOriginIsolated || typeof SharedArrayBuffer === 'undefined') {
+ // Usually the start page's service worker wasn't in control yet (Safari
+ // right after a deploy). Reload the whole page once so it is; the time
+ // guard stops a loop where isolation is impossible (Private tabs).
+ var render360LastIsolationReload = 0;
+ try { render360LastIsolationReload = Number(sessionStorage.getItem('render360PagesReloadAt')) || 0; } catch (_) {}
+ if (Date.now() - render360LastIsolationReload > 20000) {
+ try { sessionStorage.setItem('render360PagesReloadAt', String(Date.now())); } catch (_) {}
+ render360AppendOutput('[Render360 baseline] not isolated; reloading the start page');
+ try { (window.top || window).location.reload(); } catch (_) { window.location.reload(); }
+ throw new Error('Render360: reloading for cross-origin isolation');
+ }
render360ShowFailure('Portal can’t start here',
'This page is not cross-origin isolated, so threads are unavailable. Open it from the Portal start page, not a Private tab.');
}
diff --git a/materialsystem/shaderapidx9/shaderdevicedx8.cpp b/materialsystem/shaderapidx9/shaderdevicedx8.cpp
index d44060917b..4bec1f201b 100644
--- a/materialsystem/shaderapidx9/shaderdevicedx8.cpp
+++ b/materialsystem/shaderapidx9/shaderdevicedx8.cpp
@@ -1462,10 +1462,15 @@ bool CShaderDeviceMgrDx8::ValidateMode( int nAdapter, const ShaderDeviceInfo_t &
// settings
GetCurrentModeInfo( &displayMode, 0 );
+#ifndef __EMSCRIPTEN__
// make sure the window fits within the current video mode
+ // (In the browser the GL layer reports a fixed 640x480 display, while
+ // the canvas can be any size the page asks for; a page-sized window
+ // was rejected here and the engine quit before showing anything.)
if ( ( info.m_DisplayMode.m_nWidth > displayMode.m_nWidth ) ||
( info.m_DisplayMode.m_nHeight > displayMode.m_nHeight ) )
return false;
+#endif
}
else
{