diff --git a/moli-renderer-v8/src/context_bootstrap/canvas/webgl.rs b/moli-renderer-v8/src/context_bootstrap/canvas/webgl.rs index 9c1255fb6..b6715886e 100644 --- a/moli-renderer-v8/src/context_bootstrap/canvas/webgl.rs +++ b/moli-renderer-v8/src/context_bootstrap/canvas/webgl.rs @@ -12,6 +12,7 @@ const WEBGL_VIEWPORT_SLOT: &str = "__moliWebGlViewport"; const WEBGL_ERROR_SLOT: &str = "__moliWebGlError"; const WEBGL_EXTENSIONS_SLOT: &str = "__moliWebGlExtensions"; const WEBGL_VIEWPORT: u32 = 0x0BA2; +const WEBGL_INVALID_ENUM: u32 = 0x0500; const WEBGL_INVALID_VALUE: u32 = 0x0501; const WEBGL_MAX_VIEWPORT_DIMS: [i32; 2] = [8192, 8192]; const WEBGL2_DRAWING_BUFFER_COLOR_SPACE_SLOT: &str = "__moliWebGl2DrawingBufferColorSpace"; @@ -48,6 +49,15 @@ struct WebGlGetParameterArgs { pname: u32, } +#[derive(webidl::WebIdlArgs)] +#[webidl(prefix = "WebGLRenderingContext.getShaderPrecisionFormat")] +struct WebGlGetShaderPrecisionFormatArgs { + #[webidl(required)] + shader_type: u32, + #[webidl(required)] + precision_type: u32, +} + #[derive(webidl::WebIdlArgs)] #[webidl(prefix = "WebGLRenderingContext.viewport")] struct WebGlViewportArgs { @@ -67,7 +77,7 @@ struct WebGlContextStateDeclaration<'s> { #[webapi(slot = WEBGL_VIEWPORT_SLOT)] viewport: v8::Local<'s, v8::Array>, #[webapi(slot = WEBGL_ERROR_SLOT)] - error: u32, + errors: v8::Local<'s, v8::Set>, #[webapi(slot = WEBGL_EXTENSIONS_SLOT)] extensions: v8::Local<'s, v8::Map>, } @@ -151,6 +161,7 @@ struct WebGl2ContextObjectDeclaration { pub(crate) const WEBGL_CONSTANTS: &[(&str, u32)] = &[ ("VIEWPORT", WEBGL_VIEWPORT), ("NO_ERROR", 0), + ("INVALID_ENUM", WEBGL_INVALID_ENUM), ("INVALID_VALUE", WEBGL_INVALID_VALUE), ("DEPTH_TEST", 0x0B71), ("LEQUAL", 0x0203), @@ -193,6 +204,7 @@ pub(crate) const WEBGL_CONSTANTS: &[(&str, u32)] = &[ ("LOW_INT", 0x8DF3), ("MEDIUM_INT", 0x8DF4), ("HIGH_INT", 0x8DF5), + ("COMPRESSED_TEXTURE_FORMATS", 0x86A3), ]; pub(crate) const WEBGL2_CONSTANTS: &[(&str, u32)] = &[ @@ -227,7 +239,6 @@ pub(crate) const WEBGL2_CONSTANTS: &[(&str, u32)] = &[ ("MAX_SERVER_WAIT_TIMEOUT", 0x9111), ("MAX_ELEMENT_INDEX", 0x8D6B), ("MAX_CLIENT_WAIT_TIMEOUT_WEBGL", 0x9247), - ("COMPRESSED_TEXTURE_FORMATS", 0x86A3), ("RENDERBUFFER", 0x8D41), ("SAMPLES", 0x80A9), ("FRAMEBUFFER", 0x8D40), @@ -364,15 +375,17 @@ pub(crate) fn webgl_get_parameter_callback<'s>( webgl2_get_parameter_callback(scope, args, rv); return; } + let Some(extensions) = webgl_extensions(scope, args.this()) else { + return; + }; let Some(parsed) = webidl::parse_args::(scope, &args) else { rv.set_undefined(); return; }; match parsed.pname { WEBGL_VIEWPORT => return_webgl_viewport(scope, args.this(), &mut rv), - 0x846D | 0x846E => rv.set( - webgl_array_value(scope, &[1, 1]).unwrap_or_else(|| v8::Array::new(scope, 0).into()), - ), + 0x846D | 0x846E => rv.set(webgl_float32_array(scope, &[1.0, 1.0])), + 0x86A3 => rv.set(webgl_uint32_array(scope, &[])), 0x0D3A => rv.set(webgl_int32_array(scope, &WEBGL_MAX_VIEWPORT_DIMS)), 0x0D52..=0x0D55 => rv.set(v8::Integer::new(scope, 8).into()), 0x0D56 => rv.set(v8::Integer::new(scope, 24).into()), @@ -382,11 +395,19 @@ pub(crate) fn webgl_get_parameter_callback<'s>( 0x8872 | 0x8B4C => rv.set(v8::Integer::new(scope, 8).into()), 0x8B4D => rv.set(v8::Integer::new(scope, 16).into()), 0x8DFB..=0x8DFD => rv.set(v8::Integer::new(scope, 128).into()), - 0x1F00 | 0x9245 => rv.set(v8::String::empty(scope).into()), - 0x1F01 | 0x9246 => rv.set(v8::String::empty(scope).into()), - 0x1F02 => rv.set(v8::String::new(scope, "WebGL 1.0").unwrap().into()), - 0x8B8C => rv.set(v8::String::new(scope, "WebGL GLSL ES 1.0").unwrap().into()), - _ => rv.set(v8::null(scope).into()), + // These masked API strings match the Chromium service surface, not a + // claim about the physical GPU. Unmasked hardware identity stays empty. + 0x1F00 => rv.set(v8str(scope, "WebKit").into()), + 0x1F01 => rv.set(v8str(scope, "WebKit WebGL").into()), + 0x9245 | 0x9246 if webgl_debug_renderer_info_enabled(scope, extensions) => { + rv.set(v8::String::empty(scope).into()) + } + 0x1F02 => rv.set(v8str(scope, "WebGL 1.0 (OpenGL ES 2.0 Chromium)").into()), + 0x8B8C => rv.set(v8str(scope, "WebGL GLSL ES 1.0 (OpenGL ES GLSL ES 1.0 Chromium)").into()), + _ => { + record_webgl_error(scope, args.this(), WEBGL_INVALID_ENUM); + rv.set_null(); + } } } @@ -395,6 +416,9 @@ pub(crate) fn webgl2_get_parameter_callback<'s>( args: v8::FunctionCallbackArguments<'s>, mut rv: v8::ReturnValue<'_, v8::Value>, ) { + let Some(extensions) = webgl_extensions(scope, args.this()) else { + return; + }; let Some(parsed) = webidl::parse_args::(scope, &args) else { rv.set_undefined(); return; @@ -425,7 +449,9 @@ pub(crate) fn webgl2_get_parameter_callback<'s>( 0x8A34 => rv.set(v8::Integer::new(scope, 256).into()), 0x1F00 => rv.set(v8str(scope, "WebKit").into()), 0x1F01 => rv.set(v8str(scope, "WebKit WebGL").into()), - 0x9245 | 0x9246 => rv.set(v8::String::empty(scope).into()), + 0x9245 | 0x9246 if webgl_debug_renderer_info_enabled(scope, extensions) => { + rv.set(v8::String::empty(scope).into()) + } 0x1F02 => rv.set(v8str(scope, "WebGL 2.0 (OpenGL ES 3.0 Chromium)").into()), 0x8B8C => { rv.set(v8str(scope, "WebGL GLSL ES 3.00 (OpenGL ES GLSL ES 3.0 Chromium)").into()) @@ -433,17 +459,28 @@ pub(crate) fn webgl2_get_parameter_callback<'s>( 0x86A3 => rv.set(webgl_uint32_array(scope, &[])), 0x0D33 | 0x84E8 | 0x851C => rv.set(v8::Integer::new(scope, 8192).into()), 0x0D3A => rv.set(webgl_int32_array(scope, &WEBGL_MAX_VIEWPORT_DIMS)), - 0x846D | 0x846E => rv.set(webgl_int32_array(scope, &[1, 1])), + 0x846D | 0x846E => rv.set(webgl_float32_array(scope, &[1.0, 1.0])), 0x0D52..=0x0D55 => rv.set(v8::Integer::new(scope, 8).into()), 0x0D56 => rv.set(v8::Integer::new(scope, 24).into()), 0x0D57 => rv.set(v8::Integer::new(scope, 0).into()), 0x8869 => rv.set(v8::Integer::new(scope, 16).into()), 0x8872 | 0x8B4C => rv.set(v8::Integer::new(scope, 16).into()), 0x8B4D => rv.set(v8::Integer::new(scope, 64).into()), - _ => rv.set(v8::null(scope).into()), + _ => { + record_webgl_error(scope, args.this(), WEBGL_INVALID_ENUM); + rv.set_null(); + } } } +fn webgl_debug_renderer_info_enabled( + scope: &mut v8::PinScope<'_, '_>, + extensions: v8::Local<'_, v8::Map>, +) -> bool { + let key = v8str(scope, WebGlExtension::DebugRendererInfo.into()).into(); + extensions.has(scope, key) == Some(true) +} + pub(crate) fn webgl2_get_internalformat_parameter_callback<'s>( scope: &mut v8::PinScope<'s, '_>, args: v8::FunctionCallbackArguments<'s>, @@ -486,6 +523,21 @@ fn webgl_int32_array<'s>( .into() } +fn webgl_float32_array<'s>( + scope: &mut v8::PinScope<'s, '_>, + values: &[f32], +) -> v8::Local<'s, v8::Value> { + let mut bytes = Vec::with_capacity(std::mem::size_of_val(values)); + for value in values { + bytes.extend_from_slice(&value.to_ne_bytes()); + } + let backing_store = v8::ArrayBuffer::new_backing_store_from_vec(bytes).make_shared(); + let buffer = v8::ArrayBuffer::with_backing_store(scope, &backing_store); + v8::Float32Array::new(scope, buffer, 0, values.len()) + .expect("WebGL Float32Array construction should succeed") + .into() +} + fn webgl_uint32_array<'s>( scope: &mut v8::PinScope<'s, '_>, values: &[u32], @@ -588,17 +640,35 @@ pub(crate) fn webgl_get_error_callback<'s>( args: v8::FunctionCallbackArguments<'s>, mut rv: v8::ReturnValue<'_, v8::Value>, ) { - let Some(error) = get_private_value(scope, args.this(), WEBGL_ERROR_SLOT) else { + let Some(errors) = get_private_value(scope, args.this(), WEBGL_ERROR_SLOT) + .and_then(|value| v8::Local::::try_from(value).ok()) + else { throw_type_error(scope, "Illegal invocation"); return; }; + if errors.size() == 0 { + rv.set_uint32(0); + return; + } + let error = errors + .as_array(scope) + .get_index(scope, 0) + .expect("pending WebGL errors have an own first entry"); + errors.delete(scope, error); rv.set(error); - set_private_value( - scope, - args.this(), - WEBGL_ERROR_SLOT, - v8::Integer::new(scope, 0).into(), - ); +} + +fn record_webgl_error<'s>( + scope: &mut v8::PinScope<'s, '_>, + context: v8::Local<'s, v8::Object>, + error: u32, +) { + let errors = get_private_value(scope, context, WEBGL_ERROR_SLOT) + .and_then(|value| v8::Local::::try_from(value).ok()) + .expect("validated WebGL receiver has error state"); + // Like Blink's synthetic error list, preserve distinct pending errors but + // coalesce repeated occurrences until getError consumes them. + errors.add(scope, v8::Integer::new_from_unsigned(scope, error).into()); } pub(crate) fn webgl_viewport_callback<'s>( @@ -616,12 +686,7 @@ pub(crate) fn webgl_viewport_callback<'s>( if parsed.width < 0 || parsed.height < 0 { // GL errors preserve the previous viewport and remain pending until // getError consumes them. WebIDL conversion failures throw instead. - set_private_value( - scope, - args.this(), - WEBGL_ERROR_SLOT, - v8::Integer::new_from_unsigned(scope, WEBGL_INVALID_VALUE).into(), - ); + record_webgl_error(scope, args.this(), WEBGL_INVALID_VALUE); return; } set_webgl_viewport( @@ -675,7 +740,8 @@ pub(super) fn init_webgl_context_object<'s>( let values = [0, 0, 300, 150].map(|value| v8::Integer::new(scope, value).into()); let viewport = v8::Array::new_with_elements(scope, &values); let extensions = v8::Map::new(scope); - WebGlContextStateDeclaration::new(viewport, 0, extensions) + let errors = v8::Set::new(scope); + WebGlContextStateDeclaration::new(viewport, errors, extensions) .initialize(scope, context) .expect("WebGL context state should initialize"); } @@ -778,12 +844,36 @@ pub(crate) fn webgl_is_context_lost_callback( rv.set(v8::Boolean::new(scope, false).into()); } -pub(crate) fn webgl_get_shader_precision_format_callback( - scope: &mut v8::PinScope<'_, '_>, - _args: v8::FunctionCallbackArguments<'_>, +pub(crate) fn webgl_get_shader_precision_format_callback<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, mut rv: v8::ReturnValue<'_, v8::Value>, ) { - let value = WebGlShaderPrecisionFormat::default() + if webgl_extensions(scope, args.this()).is_none() { + return; + } + let Some(parsed) = webidl::parse_args::(scope, &args) else { + return; + }; + if !matches!(parsed.shader_type, 0x8B30 | 0x8B31) { + record_webgl_error(scope, args.this(), WEBGL_INVALID_ENUM); + rv.set_null(); + return; + } + // A stable software-GL precision profile. Integer precision is always 0; + // the floating-point mantissa width is not meaningful for integer formats. + let (range_min, range_max, precision) = match parsed.precision_type { + 0x8DF0 | 0x8DF1 => (15, 15, 10), + 0x8DF2 => (127, 127, 23), + 0x8DF3 | 0x8DF4 => (15, 14, 0), + 0x8DF5 => (31, 30, 0), + _ => { + record_webgl_error(scope, args.this(), WEBGL_INVALID_ENUM); + rv.set_null(); + return; + } + }; + let value = WebGlShaderPrecisionFormat::new(precision, range_min, range_max) .bind(scope) .expect("WebGL shader precision format declaration should bind"); rv.set(value.into()); @@ -804,16 +894,6 @@ struct WebGlShaderPrecisionFormat { range_max: i32, } -impl Default for WebGlShaderPrecisionFormat { - fn default() -> Self { - Self { - precision: 23, - range_min: 127, - range_max: 127, - } - } -} - pub(crate) fn webgl_lose_context_noop_callback( _scope: &mut v8::PinScope<'_, '_>, _args: v8::FunctionCallbackArguments<'_>, diff --git a/moli-renderer-v8/src/script_vm/tests/canvas_webgl.rs b/moli-renderer-v8/src/script_vm/tests/canvas_webgl.rs index d3c8c20ac..4b8aa0ff2 100644 --- a/moli-renderer-v8/src/script_vm/tests/canvas_webgl.rs +++ b/moli-renderer-v8/src/script_vm/tests/canvas_webgl.rs @@ -1,5 +1,17 @@ use super::*; +#[test] +fn webgl_basic_queries_preserve_types_extension_gates_and_errors() { + let mut vm = new_storage_test_vm("https://webgl-query-contract.test/"); + let result = vm + .eval(include_str!("../../../tests/fixtures/webgl-queries.js")) + .expect("WebGL queries should preserve their types and error contracts"); + assert_eq!( + result, + r#"["html:webgl","offscreen:webgl","html:webgl2","offscreen:webgl2"]"# + ); +} + #[test] fn webgl_extension_registry_and_context_identity_are_consistent() { let mut vm = new_storage_test_vm("https://webgl-extensions.test/"); @@ -461,7 +473,7 @@ fn webgl_fingerprint_pipeline_methods_are_available() { assert_eq!( result, - r#"{"methods":["function","function","function","function","function"],"handles":["object","object","object","object"],"attrib":0,"constants":[34962,35044,35633,35632,5],"descriptor":[34962,true,false,false],"ownerCanvas":{"same":true,"ownEnumerable":false,"dataUrl":"string","hashTail":"SuQmCC"},"parameters":{"line":[1,1],"viewport":[8192,8192],"redBits":8,"vendor":"","version":"WebGL 1.0","missing":null},"precision":{"precision":23,"rangeMin":127,"rangeMax":127},"shaderStatus":true,"programStatus":true,"shaderLog":"","error":0}"# + r#"{"methods":["function","function","function","function","function"],"handles":["object","object","object","object"],"attrib":0,"constants":[34962,35044,35633,35632,5],"descriptor":[34962,true,false,false],"ownerCanvas":{"same":true,"ownEnumerable":false,"dataUrl":"string","hashTail":"SuQmCC"},"parameters":{"line":[1,1],"viewport":[8192,8192],"redBits":8,"vendor":"WebKit","version":"WebGL 1.0 (OpenGL ES 2.0 Chromium)","missing":null},"precision":{"precision":23,"rangeMin":127,"rangeMax":127},"shaderStatus":true,"programStatus":true,"shaderLog":"","error":1280}"# ); } diff --git a/moli-renderer-v8/src/worker/thread/tests/postmessage.rs b/moli-renderer-v8/src/worker/thread/tests/postmessage.rs index e723154b1..b8582ad14 100644 --- a/moli-renderer-v8/src/worker/thread/tests/postmessage.rs +++ b/moli-renderer-v8/src/worker/thread/tests/postmessage.rs @@ -2082,6 +2082,26 @@ async fn worker_offscreen_canvas_exposes_webgl_identity_consistently() { ); } +#[tokio::test] +async fn worker_webgl_queries_match_the_window_contract() { + ensure_v8(); + let mut handle = spawn_worker( + format!( + "postMessage(JSON.parse({})); close();", + include_str!("../../../../tests/fixtures/webgl-queries.js") + ), + "test://worker_webgl_queries".into(), + ); + let msg = timeout(TIMEOUT, handle.recv()) + .await + .expect("timed out") + .expect("channel closed"); + assert_eq!( + expect_post_json(msg), + r#"["offscreen:webgl","offscreen:webgl2"]"# + ); +} + #[tokio::test] async fn worker_webgl_extensions_use_intrinsic_objects_and_context_local_identity() { ensure_v8(); diff --git a/moli-renderer-v8/tests/fixtures/webgl-queries.js b/moli-renderer-v8/tests/fixtures/webgl-queries.js new file mode 100644 index 000000000..483aad5f1 --- /dev/null +++ b/moli-renderer-v8/tests/fixtures/webgl-queries.js @@ -0,0 +1,64 @@ +// No GPU-specific identity or numeric limit is assumed by this contract probe. +(() => { + const results = []; + for (const kind of ['webgl', 'webgl2']) { + for (const offscreen of typeof document === 'undefined' ? [true] : [false, true]) { + const label = `${offscreen ? 'offscreen' : 'html'}:${kind}`; + const check = (condition, message) => { + if (!condition) throw new Error(`${label}: ${message}`); + }; + const create = () => (offscreen ? new OffscreenCanvas(1, 1) : document.createElement('canvas')).getContext(kind); + const gl = create(); + check(gl.getParameter(gl.VENDOR) === 'WebKit', 'masked vendor'); + check(gl.getParameter(gl.RENDERER) === 'WebKit WebGL', 'masked renderer'); + check(gl.getParameter(gl.VERSION).startsWith(kind === 'webgl' ? 'WebGL 1.0 (' : 'WebGL 2.0 ('), 'version format'); + check(gl.getParameter(gl.SHADING_LANGUAGE_VERSION).startsWith('WebGL GLSL ES '), 'shading language version'); + gl.getSupportedExtensions(); + check(gl.getParameter(37445) === null, 'enumerating extensions does not enable debug queries'); + check(gl.getError() === gl.INVALID_ENUM, 'disabled debug query signals an error'); + const debug = gl.getExtension('webgl_debug_renderer_info'); + for (const pname of [debug.UNMASKED_VENDOR_WEBGL, debug.UNMASKED_RENDERER_WEBGL]) { + check(typeof gl.getParameter(pname) === 'string', 'enabled debug query returns a string'); + } + const other = create(); + check(other.getParameter(37446) === null && other.getError() === other.INVALID_ENUM, 'activation is context-local'); + for (const pname of [gl.ALIASED_LINE_WIDTH_RANGE, gl.ALIASED_POINT_SIZE_RANGE]) { + const range = gl.getParameter(pname); + check(range instanceof Float32Array && range.length === 2, 'range query is Float32Array'); + const first = range[0]; + range[0] = NaN; + check(gl.getParameter(pname)[0] === first, 'range query returns a copy'); + } + check(gl.getParameter(gl.COMPRESSED_TEXTURE_FORMATS) instanceof Uint32Array, 'compressed formats query type'); + check(gl.getParameter(gl.MAX_VIEWPORT_DIMS) instanceof Int32Array, 'viewport limits query type'); + for (const shader of [gl.VERTEX_SHADER, gl.FRAGMENT_SHADER]) { + for (const name of ['LOW_INT', 'MEDIUM_INT', 'HIGH_INT']) { + const precision = gl.getShaderPrecisionFormat(shader, gl[name]); + check(precision.precision === 0 && precision.rangeMin > 0 && precision.rangeMax > 0, 'integer precision'); + } + check(gl.getShaderPrecisionFormat(shader, gl.HIGH_FLOAT).precision > 0, 'floating-point precision'); + } + check(gl.getError() === gl.NO_ERROR, 'valid queries leave no error'); + for (const call of [() => gl.getParameter(), () => gl.getParameter.call({}, gl.VENDOR), + () => gl.getShaderPrecisionFormat(gl.VERTEX_SHADER), + () => gl.getShaderPrecisionFormat.call({}, gl.VERTEX_SHADER, gl.HIGH_FLOAT)]) { + let threw = false; + try { call(); } catch (error) { threw = error instanceof TypeError; } + check(threw, 'query checks receiver and required arguments'); + } + check(gl.getError() === gl.NO_ERROR, 'WebIDL exceptions do not create GL errors'); + check(gl.getParameter(0xffffffff) === null, 'unknown enum returns null'); + check(gl.getParameter(0xffffffff) === null, 'repeat invalid query returns null'); + gl.viewport(0, 0, -1, 1); + check(gl.getError() === gl.INVALID_ENUM, 'first pending error'); + check(gl.getError() === gl.INVALID_VALUE, 'distinct error is preserved'); + check(gl.getError() === gl.NO_ERROR, 'repeated pending errors coalesce'); + check(gl.getShaderPrecisionFormat(0, gl.HIGH_FLOAT) === null, 'invalid shader enum'); + check(gl.getError() === gl.INVALID_ENUM, 'invalid shader enum error'); + check(gl.getShaderPrecisionFormat(gl.VERTEX_SHADER, 0) === null, 'invalid precision enum'); + check(gl.getError() === gl.INVALID_ENUM, 'invalid precision enum error'); + results.push(label); + } + } + return JSON.stringify(results); +})()