three-realtime-rt 0.6.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -193,6 +193,27 @@ void fetchMaterial(float matIndex, out vec3 albedo, out float roughness,
193
193
  metalness = t1.a;
194
194
  }
195
195
 
196
+ // World-space 3D-texture albedo ("volumetric surface albedo") for the traced
197
+ // SECONDARY rays (GI bounces + reflection/refraction), so global illumination and
198
+ // mirror views carry the same field colours the primary G-buffer shows. Compiled
199
+ // in ONLY behind RT_VOLUME_ALBEDO: this megakernel already sits at the WebGL2
200
+ // 16-sampler minimum, so the extra sampler3D is added exclusively when a scene
201
+ // registers a volume AND the GPU exposes >= 17 fragment texture units (the
202
+ // RealtimeRaytracer gates both conditions). Absent, the shader is textually
203
+ // identical to the pre-feature megakernel — same 16 samplers, same program. v1 is
204
+ // single-volume: one texture + one material index; a hit on that material samples
205
+ // the field, every other hit reads its flat table albedo unchanged.
206
+ #ifdef RT_VOLUME_ALBEDO
207
+ uniform highp sampler3D uVolumeTex;
208
+ uniform vec3 uVolumeOrigin;
209
+ uniform vec3 uVolumeSize;
210
+ uniform int uVolumeMatIndex;
211
+ vec3 sampleVolumeAlbedo(vec3 p) {
212
+ vec3 uvw = clamp((p - uVolumeOrigin) / uVolumeSize, 0.0, 1.0);
213
+ return texture(uVolumeTex, uvw).rgb;
214
+ }
215
+ #endif
216
+
196
217
  // ---------- PBR specular (Cook-Torrance GGX) ----------
197
218
  // A separate specular radiance is accumulated for the primary surface's DIRECT
198
219
  // lighting alongside the demodulated diffuse irradiance. Because CompositePass
@@ -495,6 +516,12 @@ vec3 traceRadiance(vec3 ro, vec3 rd, bool specular) {
495
516
  vec3 hN = normalize(attr.xyz);
496
517
  if (dot(hN, rd) > 0.0) hN = -hN;
497
518
  vec3 hP = ro + rd * dist;
519
+ // Volumetric surface albedo: if this hit is the volume material, replace its
520
+ // flat table albedo with the 3D-texture sample at the world hit point, so GI /
521
+ // reflection bounces carry the field colours (matches the primary G-buffer).
522
+ #ifdef RT_VOLUME_ALBEDO
523
+ if (int(round(attr.w)) == uVolumeMatIndex) hAlbedo = sampleVolumeAlbedo(hP);
524
+ #endif
498
525
  vec3 Ld = sampleOneAny(hP + hN * uEps, hN);
499
526
  vec3 hLe = (!specular && uEmissiveCount > 0) ? vec3(0.0) : hEmissive;
500
527
  return hLe + hAlbedo * Ld * (1.0 / PI);
@@ -1001,6 +1028,11 @@ export class RTLightingPass {
1001
1028
  // RealtimeRaytracer._passClass -> coreFailure).
1002
1029
  name: "rt:lighting",
1003
1030
  glslVersion: THREE.GLSL3,
1031
+ // RT_VOLUME_ALBEDO is injected only when a scene uses world-space 3D-texture
1032
+ // albedo AND the GPU has a spare fragment sampler (see setVolumeAlbedo +
1033
+ // RealtimeRaytracer). Absent, this megakernel is textually identical to the
1034
+ // pre-feature build — same 16 samplers, same Metal translation.
1035
+ defines: {},
1004
1036
  vertexShader: fullscreenVert,
1005
1037
  fragmentShader: specMRT
1006
1038
  ? rtLightingFrag
@@ -1054,6 +1086,13 @@ export class RTLightingPass {
1054
1086
  uSkyZenith: { value: new THREE.Color(0.18, 0.34, 0.62) },
1055
1087
  uSkyHorizon: { value: new THREE.Color(0.7, 0.8, 0.9) },
1056
1088
  uSkyIntensity: { value: 1.0 },
1089
+ // World-space 3D-texture albedo (present only in the compiled program when
1090
+ // the RT_VOLUME_ALBEDO define is set; harmless otherwise — three uploads
1091
+ // only uniforms the program actually declares).
1092
+ uVolumeTex: { value: null },
1093
+ uVolumeOrigin: { value: new THREE.Vector3() },
1094
+ uVolumeSize: { value: new THREE.Vector3(1, 1, 1) },
1095
+ uVolumeMatIndex: { value: -1 },
1057
1096
  },
1058
1097
  depthTest: false,
1059
1098
  depthWrite: false,
@@ -1237,6 +1276,36 @@ export class RTLightingPass {
1237
1276
  u.uEmissiveCount.value = compiled.emissiveTriCount;
1238
1277
  }
1239
1278
 
1279
+ /**
1280
+ * Enable/disable the world-space 3D-texture albedo path for the traced
1281
+ * SECONDARY rays (GI + reflections). Pass the compiled `volumeAlbedo` descriptor
1282
+ * ({ texture, origin, size, matIndex }) to turn it on, or null to turn it off.
1283
+ * Toggling the RT_VOLUME_ALBEDO define recompiles this megakernel (adds/removes
1284
+ * the single sampler3D), which the caller does at compile time, not per frame.
1285
+ * The caller is responsible for only enabling this when the GPU has a spare
1286
+ * fragment texture unit — this pass is at the 16-sampler minimum, so the 17th
1287
+ * sampler would fail to link on a bare-minimum WebGL2 device.
1288
+ */
1289
+ setVolumeAlbedo(volume) {
1290
+ const wasOn = this.material.defines.RT_VOLUME_ALBEDO !== undefined;
1291
+ const on = !!volume;
1292
+ const u = this.material.uniforms;
1293
+ if (on) {
1294
+ u.uVolumeTex.value = volume.texture;
1295
+ u.uVolumeOrigin.value.copy(volume.origin);
1296
+ u.uVolumeSize.value.copy(volume.size);
1297
+ u.uVolumeMatIndex.value = volume.matIndex;
1298
+ } else {
1299
+ u.uVolumeTex.value = null;
1300
+ u.uVolumeMatIndex.value = -1;
1301
+ }
1302
+ if (on !== wasOn) {
1303
+ if (on) this.material.defines.RT_VOLUME_ALBEDO = "1";
1304
+ else delete this.material.defines.RT_VOLUME_ALBEDO;
1305
+ this.material.needsUpdate = true; // recompile with/without the sampler3D
1306
+ }
1307
+ }
1308
+
1240
1309
  /**
1241
1310
  * Renders lighting into targetA (reading targetB as irradiance history), then
1242
1311
  * accumulates the fresh specular (targetA attachment 1) into specA (reading
@@ -349,6 +349,19 @@ uniform sampler2D uTex; void main(){ outColor = texture(uTex, vUv); }`,
349
349
  static DIAG_STABLE_FRAMES = 4;
350
350
  static DIAG_WINDOW_FRAMES = 45;
351
351
 
352
+ // Staleness scan (see _checkStale): a static mesh edited after compileScene()
353
+ // keeps tracing its compile-time shape/place. Checked every Nth frame only —
354
+ // the comparison is an int and 16 floats per static source, and each source
355
+ // stops being checked as soon as it has reported — and capped so a scene that
356
+ // moves everything can never flood the console.
357
+ static STALE_CHECK_FRAMES = 30;
358
+ static MAX_STALE_WARNINGS = 8;
359
+
360
+ // Largest renderScale change the adaptive governor may commit in one step
361
+ // (0.25 = five 0.05 ladder steps). Bounds the reaction to a single very slow
362
+ // frame now that 100ms-2s frames feed the EMA; see _adaptQuality.
363
+ static MAX_SCALE_STEP = 0.25;
364
+
352
365
  constructor(renderer, options = {}) {
353
366
  this.renderer = renderer;
354
367
 
@@ -370,7 +383,7 @@ uniform sampler2D uTex; void main(){ outColor = texture(uTex, vUv); }`,
370
383
  // the RT pipeline is not operational at all; `supported` is the primary
371
384
  // signal, but status is kept consistent so integrators can read one field.
372
385
  this.compileError = null;
373
- this.status = { ok: false, disabled: [], coreFailure: null };
386
+ this.status = { ok: false, disabled: [], coreFailure: null, warnings: [] };
374
387
  this._diagDone = true;
375
388
  return;
376
389
  }
@@ -422,6 +435,18 @@ uniform sampler2D uTex; void main(){ outColor = texture(uTex, vUv); }`,
422
435
  "(WebKit/iOS) — specular buffer disabled, alpha-blend surfaces render opaque."
423
436
  );
424
437
  }
438
+ // Fragment-shader texture-unit budget. The lighting megakernel already binds
439
+ // exactly the WebGL2-guaranteed minimum of 16 fragment samplers, so the
440
+ // world-space 3D-texture-albedo feature's SECONDARY-ray path (an extra
441
+ // sampler3D) can only be compiled in on a GPU that exposes >= 17. Primary
442
+ // visibility (the G-buffer, which has ample headroom) samples the volume
443
+ // regardless; only the traced GI/reflection bounce is gated on this. Most
444
+ // desktop GPUs report 32.
445
+ this._maxFragTexUnits = renderer.getContext().getParameter(
446
+ renderer.getContext().MAX_TEXTURE_IMAGE_UNITS
447
+ );
448
+ this._volumeUnitWarned = false;
449
+
425
450
  this.gbuffer = new GBufferPass(this._width, this._height, { mixedPrecision });
426
451
  this.rtPass = new RTLightingPass(this._scaledW, this._scaledH, {
427
452
  specMRT: this.specMRTSupported,
@@ -728,14 +753,25 @@ uniform sampler2D uTex; void main(){ outColor = texture(uTex, vUv); }`,
728
753
  // off to keep the image lit (e.g. taa, denoise, restir).
729
754
  // coreFailure string | null — a core pass (gbuffer/lighting/composite)
730
755
  // failed and has no fallback; the image is black-but-diagnosed.
756
+ // warnings [{ code, message }] — USAGE diagnostics (a flag that is
757
+ // being ignored, an object type that cannot be traced, a
758
+ // static mesh edited after compileScene()). Each is also
759
+ // console.warn'd once. These never affect `ok`: the
760
+ // pipeline is healthy, the SCENE SETUP is not what the app
761
+ // probably intended.
731
762
  this.compileError = null;
732
- this.status = { ok: true, disabled: [], coreFailure: null };
763
+ this.status = { ok: true, disabled: [], coreFailure: null, warnings: [] };
733
764
  this._diagDone = false; // set once the polling window settles
734
765
  this._diagFrames = 0; // rendered frames scanned so far
735
766
  this._diagStable = 0; // consecutive scans with an unchanged rt:* program set
736
767
  this._diagSig = ""; // signature of the rt:* program-name set last scan
737
768
  this._diagHandled = new Set(); // rt:* names already acted on (warn-once)
738
769
  this._compileErrSev = -1; // severity behind the current compileError (2/1/0)
770
+
771
+ // Usage-diagnostic state (see _checkStale / _warn).
772
+ this._staleDone = false; // nothing left worth scanning
773
+ this._staleWarnings = 0; // stale reports emitted (capped)
774
+ this._implicitCompileWarned = false;
739
775
  }
740
776
 
741
777
  // Classify an rt:* pass program by how a LINK failure degrades. CORE passes
@@ -890,6 +926,102 @@ uniform sampler2D uTex; void main(){ outColor = texture(uTex, vUv); }`,
890
926
  }
891
927
  }
892
928
 
929
+ // ---- usage diagnostics (status.warnings) ---------------------------------
930
+ // A usage warning is console.warn'd ONCE and recorded on status.warnings so a
931
+ // UI (or an automated check) can read the same signal. `ok` is untouched: the
932
+ // pipeline is fine, the scene setup is what looks wrong. Duplicate
933
+ // code+message pairs are collapsed so a recompile can't grow the array.
934
+ _warn(code, message) {
935
+ console.warn(message);
936
+ this._recordWarning(code, message);
937
+ }
938
+
939
+ _recordWarning(code, message) {
940
+ const list = this.status && this.status.warnings;
941
+ if (!list) return;
942
+ for (let i = 0; i < list.length; i++) {
943
+ if (list[i].code === code && list[i].message === message) return;
944
+ }
945
+ list.push({ code, message });
946
+ }
947
+
948
+ // Mirror the compiler's usage diagnostics onto the status surface. The
949
+ // compiler already wrote them to the console (once per offending object);
950
+ // this only makes them readable.
951
+ _absorbCompilerWarnings(compiled) {
952
+ const w = compiled && compiled.warnings;
953
+ if (!w || w.length === 0) return;
954
+ for (let i = 0; i < w.length; i++) this._recordWarning(w[i].code, w[i].message);
955
+ }
956
+
957
+ // Detect a STATIC mesh that was edited after compileScene(): its vertices were
958
+ // deformed, or it was moved. Either way the traced lighting still uses the
959
+ // shape/place baked into the static BVH, which reads as "the shadow doesn't
960
+ // move" or "rays still hit the original shape" — the single most common
961
+ // integration mistake, and completely silent before this.
962
+ //
963
+ // Cost: runs on every STALE_CHECK_FRAMES-th frame only, over a plain array of
964
+ // fingerprints, with no allocation; each source is checked until it warns once
965
+ // (or its mesh is collected), and the whole scan switches off when nothing is
966
+ // left to report or the warning cap is reached.
967
+ _checkStale() {
968
+ if (this._staleDone) return;
969
+ const srcs = this.compiled && this.compiled.staticSources;
970
+ if (!srcs || srcs.length === 0) { this._staleDone = true; return; }
971
+ let live = 0;
972
+ for (let i = 0; i < srcs.length; i++) {
973
+ const s = srcs[i];
974
+ if (s.warned) continue;
975
+ const mesh = s.ref.deref();
976
+ if (!mesh) { s.warned = true; continue; } // collected — nothing to report
977
+ let stale = null;
978
+ const geo = mesh.geometry;
979
+ const pos = geo ? geo.getAttribute("position") : null;
980
+ if (!pos || pos.version !== s.version) {
981
+ stale = "geometry";
982
+ } else {
983
+ // Relative tolerance rather than an exact compare: an app that rebuilds
984
+ // an unchanged transform each frame (setFromEuler, a physics engine
985
+ // writing back the same pose) can land a few ULPs away, and a sub-micron
986
+ // "move" is not what this diagnostic is about.
987
+ const e = mesh.matrixWorld.elements;
988
+ const m = s.matrix;
989
+ for (let k = 0; k < 16; k++) {
990
+ const d = e[k] - m[k];
991
+ if ((d < 0 ? -d : d) > 1e-6 * (1 + (m[k] < 0 ? -m[k] : m[k]))) {
992
+ stale = "transform";
993
+ break;
994
+ }
995
+ }
996
+ }
997
+ if (!stale) { live++; continue; }
998
+ s.warned = true;
999
+ this._staleWarnings++;
1000
+ if (stale === "geometry") {
1001
+ this._warn(
1002
+ "stale-geometry",
1003
+ `three-realtime-rt: position buffer of ${s.name} changed after compileScene() ` +
1004
+ `but it is not a dynamic mesh — traced lighting still uses the ORIGINAL shape. ` +
1005
+ `Add it to compileScene(scene, {dynamicMeshes:[...]}) and set ` +
1006
+ `mesh.userData.rtDeforming = true, then call updateDynamic() each frame.`
1007
+ );
1008
+ } else {
1009
+ this._warn(
1010
+ "stale-transform",
1011
+ `three-realtime-rt: ${s.name} was moved after compileScene() but it is not a ` +
1012
+ `dynamic mesh — traced lighting still uses the ORIGINAL transform (its shadow ` +
1013
+ `stays behind). Recompile with compileScene(scene), or declare it in ` +
1014
+ `compileScene(scene, {dynamicMeshes:[...]}) and call updateDynamic() each frame.`
1015
+ );
1016
+ }
1017
+ if (this._staleWarnings >= RealtimeRaytracer.MAX_STALE_WARNINGS) {
1018
+ this._staleDone = true; // enough — do not flood the console
1019
+ return;
1020
+ }
1021
+ }
1022
+ if (live === 0) this._staleDone = true;
1023
+ }
1024
+
893
1025
  _makeColorTarget(width, height) {
894
1026
  const t = new THREE.WebGLRenderTarget(width, height, {
895
1027
  minFilter: THREE.LinearFilter,
@@ -935,6 +1067,11 @@ uniform sampler2D uTex; void main(){ outColor = texture(uTex, vUv); }`,
935
1067
  }
936
1068
  if (this.compiled) this.compiled.dispose();
937
1069
  this.compiled = compiled;
1070
+ // Usage diagnostics raised while compiling (already console.warn'd once per
1071
+ // offending object) become readable on status.warnings. A fresh compile also
1072
+ // re-arms the staleness scan against the NEW fingerprints.
1073
+ this._absorbCompilerWarnings(compiled);
1074
+ this._staleDone = false;
938
1075
  // Emissive area lights are the noisiest direct-light path: one triangle
939
1076
  // sample per pixel per frame, and the 1/dist^2 term spikes near a small
940
1077
  // emitter (fireflies). ReSTIR's reservoirs are what tame this — warn when
@@ -954,10 +1091,40 @@ uniform sampler2D uTex; void main(){ outColor = texture(uTex, vUv); }`,
954
1091
  this.volumetricPass.setCompiledScene(this.compiled);
955
1092
  this.restirPass.setCompiledScene(this.compiled);
956
1093
  this.giReservoirPass.setCompiledScene(this.compiled);
1094
+ this._syncVolumeAlbedo();
957
1095
  this.resetAccumulation();
958
1096
  return this.compiled;
959
1097
  }
960
1098
 
1099
+ /**
1100
+ * Push the compiled scene's world-space 3D-texture albedo (if any) into the
1101
+ * passes. Primary visibility (G-buffer) always gets it — it has spare samplers.
1102
+ * The traced SECONDARY-ray path (GI/reflection colour) needs a 17th fragment
1103
+ * sampler, so it is only enabled on a GPU that exposes one; on a bare-minimum
1104
+ * 16-unit device the bounces fall back to the material's flat table albedo
1105
+ * (primary visibility still shows the full field), logged once.
1106
+ */
1107
+ _syncVolumeAlbedo() {
1108
+ const vol = this.compiled ? this.compiled.volumeAlbedo : null;
1109
+ this.gbuffer.setVolume(!!vol);
1110
+ const secondaryOk = !!vol && this._maxFragTexUnits >= 17;
1111
+ // NOTE: only the inline-GI lighting pass (rtPass.traceRadiance) samples the
1112
+ // volume for the traced bounce. The experimental ReSTIR GI pass
1113
+ // (giReservoirPass, off by default) has its own GI kernel and is NOT wired
1114
+ // for volume albedo in v1 — a volume surface's indirect bounce falls back to
1115
+ // its flat table colour while restirGI is on (documented limitation).
1116
+ this.rtPass.setVolumeAlbedo(secondaryOk ? vol : null);
1117
+ if (vol && !secondaryOk && !this._volumeUnitWarned) {
1118
+ this._volumeUnitWarned = true;
1119
+ console.info(
1120
+ "[three-realtime-rt] volume albedo: this GPU exposes only " +
1121
+ `${this._maxFragTexUnits} fragment texture units (< 17 needed for the ` +
1122
+ "traced-bounce sampler), so GI / reflection bounces use the material's flat " +
1123
+ "base colour. Primary visibility still shows the full 3D-texture field."
1124
+ );
1125
+ }
1126
+ }
1127
+
961
1128
  /**
962
1129
  * Re-bake moving (dynamic) meshes into the dynamic BVH level. Call each frame
963
1130
  * after moving them. Only the dynamic level is touched — the static BVH was
@@ -1113,10 +1280,24 @@ uniform sampler2D uTex; void main(){ outColor = texture(uTex, vUv); }`,
1113
1280
  // cap the frame time can't reveal headroom, so upscaling only happens when
1114
1281
  // frames are measurably faster than the target — it never thrashes.
1115
1282
  _adaptQuality() {
1283
+ // Hidden tabs are exempt: browser throttling makes every frame look
1284
+ // catastrophic, and adapting on that would drop quality for a tab nobody is
1285
+ // watching (same rule as _overloadBrake).
1286
+ if (typeof document !== "undefined" && document.visibilityState === "hidden") {
1287
+ this._qLastT = null;
1288
+ return;
1289
+ }
1116
1290
  const now = performance.now();
1117
1291
  const dt = this._qLastT == null ? null : now - this._qLastT;
1118
1292
  this._qLastT = now;
1119
- if (dt == null || dt > 100) return; // first frame or hidden-tab stall
1293
+ if (dt == null) return; // first frame no interval yet
1294
+ // Only a genuine stall/resume (a blocked main thread, a tab coming back, a
1295
+ // debugger pause) is discarded. The old guard bailed above 100ms, which left
1296
+ // every device running slower than 10fps unable to adapt AT ALL: the
1297
+ // governor never saw a sample, and _overloadBrake only reacts past 400ms
1298
+ // with three consecutive strikes. Frames from 100ms to 2s now feed the EMA,
1299
+ // so a 3fps device walks down the ladder like any other.
1300
+ if (dt > 2000) return;
1120
1301
  this._qEma = this._qEma == null ? dt : this._qEma * 0.9 + dt * 0.1;
1121
1302
  // Calmness: normally 2s between changes. When the last two steps reversed
1122
1303
  // direction the governor is hunting the boundary, so hold for 5s AND widen
@@ -1131,6 +1312,13 @@ uniform sampler2D uTex; void main(){ outColor = texture(uTex, vUv); }`,
1131
1312
  if (ratio < dbHi && ratio > dbLo) return; // comfortable — leave it alone
1132
1313
 
1133
1314
  let s = this._renderScale * Math.pow(1 / ratio, 0.35);
1315
+ // Per-step clamp. Now that multi-hundred-millisecond frames feed the EMA, a
1316
+ // single very slow measurement (ratio can reach ~100 at dt 2s) would
1317
+ // otherwise slam the scale from 1.0 to the 0.2 floor in ONE step and throw
1318
+ // away the image on a transient. Move at most MAX_SCALE_STEP per adaptation
1319
+ // (5 ladder steps) and let the cooldown take the next one if it is still slow.
1320
+ const step = RealtimeRaytracer.MAX_SCALE_STEP;
1321
+ s = Math.min(this._renderScale + step, Math.max(this._renderScale - step, s));
1134
1322
  s = Math.round(Math.min(1, Math.max(0.2, s)) * 20) / 20; // 0.05 steps
1135
1323
 
1136
1324
  // When we're fast, give back the deepest lever FIRST: restore canvas scale
@@ -1203,7 +1391,23 @@ uniform sampler2D uTex; void main(){ outColor = texture(uTex, vUv); }`,
1203
1391
  }
1204
1392
  if (this.adaptiveQuality) this._adaptQuality();
1205
1393
  if (this.overloadProtection) this._overloadBrake();
1206
- if (!this.compiled) this.compileScene(scene);
1394
+ if (!this.compiled) {
1395
+ this.compileScene(scene);
1396
+ // An implicit compile is the zero-config path: it works, but it takes NO
1397
+ // options, so every mesh is static forever. Anything that moves needs the
1398
+ // explicit call — say so once, and only once the compile actually produced
1399
+ // a scene (an empty scene has its own warning).
1400
+ if (this.compiled && !this._implicitCompileWarned) {
1401
+ this._implicitCompileWarned = true;
1402
+ this._warn(
1403
+ "implicit-compile",
1404
+ "three-realtime-rt: render() compiled the scene implicitly (no compileScene() " +
1405
+ "call), so it was compiled with NO options — every mesh is static and " +
1406
+ "updateDynamic() has nothing to update. Call compileScene(scene, options) " +
1407
+ "yourself (e.g. {dynamicMeshes:[...]}) before the first render() if anything moves."
1408
+ );
1409
+ }
1410
+ }
1207
1411
  // Still nothing to trace (empty scene — tracer built before meshes were
1208
1412
  // added). Show the user's raster scene rather than crashing or rendering
1209
1413
  // black; the pipeline picks up automatically once compileScene() succeeds.
@@ -1213,6 +1417,8 @@ uniform sampler2D uTex; void main(){ outColor = texture(uTex, vUv); }`,
1213
1417
  }
1214
1418
 
1215
1419
  this.frame += 1;
1420
+ // Cheap periodic check that no STATIC mesh was edited behind the BVH's back.
1421
+ if (this.frame % RealtimeRaytracer.STALE_CHECK_FRAMES === 0) this._checkStale();
1216
1422
  camera.updateMatrixWorld();
1217
1423
 
1218
1424
  // --- sub-pixel jitter (TAA): offset the projection a fraction of a pixel