Browse Source

Separate stroke rendering into function. Reuse said function for static

and dynamic draw.
sdf
A.Olokhtonov 1 week ago
parent
commit
b961504b54
  1. 1
      client/config.js
  2. 1
      client/index.js
  3. 3
      client/wasm/lod.c
  4. 300
      client/webgl_draw.js
  5. 45
      client/webgl_geometry.js
  6. 5
      client/webgl_shaders.js

1
client/config.js

@ -22,7 +22,6 @@ const config = { @@ -22,7 +22,6 @@ const config = {
initial_dynamic_bytes: 4096,
initial_wasm_bytes: 4096,
stroke_texture_size: 1024, // means no more than 1024^2 = 1M strokes in total (this is a LOT. HMH blackboard has like 80K)
dynamic_stroke_texture_size: 128, // means no more than 128^2 = 16K dynamic strokes at once
ui_texture_size: 16,
bvh_fullnode_depth: 6,
pattern_fadeout_min: 0.3,

1
client/index.js

@ -232,6 +232,7 @@ async function main() { @@ -232,6 +232,7 @@ async function main() {
'dynamic_instance_points': tv_create(Float32Array, 4096),
'dynamic_instance_pressure': tv_create(Uint8Array, 4096),
'dynamic_instance_ids': tv_create(Uint32Array, 4096),
'dynamic_instance_batches': tv_create(Uint32Array, 4096),
'stroke_data': serializer_create(config.initial_static_bytes),
'dynamic_stroke_data': serializer_create(config.initial_static_bytes),

3
client/wasm/lod.c

@ -324,9 +324,8 @@ do_lod(int *clipped_indices, int clipped_count, float zoom, @@ -324,9 +324,8 @@ do_lod(int *clipped_indices, int clipped_count, float zoom,
// Compute recommended LOD level, add to current batch or start new batch
int lod;
// The LOD levels have been picked manually based on "COM" (Careful Observation Method)
float perceptual_width = width[stroke_index] * zoom;
// The LOD levels have been picked manually based on "COM" (Careful Observation Method)
// The LOD levels have been picked manually based on "COM" (Careful Observation Method) @lod
if (perceptual_width < 1.9f) {
lod = 0;
} else if (perceptual_width < 4.56f) {

300
client/webgl_draw.js

@ -96,6 +96,119 @@ function draw_html(state) { @@ -96,6 +96,119 @@ function draw_html(state) {
}
function draw_strokes(state, width, height, programs, gl, lod_levels, segment_count,
total_lod_floats, total_lod_indices,
batches_tv,
points_tv,
ids_tv,
pressures_tv,
vbo,
ebo,
stroke_texture,
stroke_data,
stroke_count,
) {
const pr = programs['main'];
// Last pair (lod unused) to have a proper from;to
tv_add2(batches_tv, segment_count);
tv_add2(batches_tv, -1);
gl.clear(gl.DEPTH_BUFFER_BIT); // draw strokes above the images
gl.useProgram(pr.program);
const total_size = points_tv.size * 4 +
ids_tv.size * 4 +
round_to_pow2(pressures_tv.size, 4) +
total_lod_floats * 4;
gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
gl.bufferData(gl.ARRAY_BUFFER, total_size, gl.STREAM_DRAW);
// Segment points, segment stroke ids, segment pressures
gl.bufferSubData(gl.ARRAY_BUFFER, 0, tv_data(points_tv));
gl.bufferSubData(gl.ARRAY_BUFFER, points_tv.size * 4, tv_data(ids_tv));
gl.bufferSubData(gl.ARRAY_BUFFER, points_tv.size * 4 + ids_tv.size * 4, tv_data(pressures_tv));
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ebo);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, total_lod_indices * 4, gl.STREAM_DRAW);
// Upload all variants of LOD vertices/indices
const base_lod_points_offset = points_tv.size * 4 + ids_tv.size * 4 + round_to_pow2(pressures_tv.size, 4);
for (const level of lod_levels) {
gl.bufferSubData(gl.ARRAY_BUFFER, base_lod_points_offset + level.vertices_offset, tv_data(level.data.points));
gl.bufferSubData(gl.ELEMENT_ARRAY_BUFFER, level.indices_offset, tv_data(level.data.indices));
}
// Per-stroke data (base width, color)
gl.bindTexture(gl.TEXTURE_2D, stroke_texture);
upload_square_rgba16ui_texture(gl, stroke_data, config.stroke_texture_size);
gl.uniform2f(pr.locations['u_res'], width, height);
gl.uniform2f(pr.locations['u_scale'], state.canvas.zoom, state.canvas.zoom);
gl.uniform2f(pr.locations['u_translation'], state.canvas.offset.x, state.canvas.offset.y);
gl.uniform1i(pr.locations['u_stroke_count'], stroke_count);
gl.uniform1i(pr.locations['u_debug_mode'], state.debug.red);
gl.uniform1i(pr.locations['u_stroke_data'], 0);
gl.uniform1i(pr.locations['u_stroke_texture_size'], config.stroke_texture_size);
gl.enableVertexAttribArray(pr.locations['a_pos']);
gl.enableVertexAttribArray(pr.locations['a_a']);
gl.enableVertexAttribArray(pr.locations['a_b']);
gl.enableVertexAttribArray(pr.locations['a_stroke_id']);
gl.enableVertexAttribArray(pr.locations['a_pressure']);
gl.vertexAttribDivisor(pr.locations['a_pos'], 0);
gl.vertexAttribDivisor(pr.locations['a_a'], 1);
gl.vertexAttribDivisor(pr.locations['a_b'], 1);
gl.vertexAttribDivisor(pr.locations['a_stroke_id'], 1);
gl.vertexAttribDivisor(pr.locations['a_pressure'], 1);
for (let b = 0; b < batches_tv.size - 2; b += 2) {
const batch_from = batches_tv.data[b + 0];
const batch_size = batches_tv.data[b + 2] - batch_from;
let lod_level = batches_tv.data[b + 1];
if (config.debug_force_lod !== null) {
lod_level = config.debug_force_lod;
}
const level = lod_levels[lod_level];
if (batch_size > 0) {
//stat_total_vertices += batch_size * level.data.indices.size;
gl.uniform1i(pr.locations['u_circle_points'], level.data.points.size / 2 - 4);
gl.uniform3f(pr.locations['u_debug_color'],
(lod_level * 785892 + 125127) % 8 / 7,
(lod_level * 901824 + 985835) % 8 / 7,
(lod_level * 232181 + 838533) % 8 / 7,
);
// Points (a, b) and stroke ids are stored in separate cpu buffers so that points can be reused (look at stride and offset values)
gl.vertexAttribPointer(pr.locations['a_a'], 2, gl.FLOAT, false, 2 * 4, batch_from * 2 * 4);
gl.vertexAttribPointer(pr.locations['a_b'], 2, gl.FLOAT, false, 2 * 4, batch_from * 2 * 4 + 2 * 4);
gl.vertexAttribIPointer(pr.locations['a_stroke_id'], 1, gl.INT, 4, points_tv.size * 4 + batch_from * 4);
gl.vertexAttribPointer(pr.locations['a_pressure'], 2, gl.UNSIGNED_BYTE, true, 1, points_tv.size * 4 + ids_tv.size * 4 + batch_from);
gl.vertexAttribPointer(pr.locations['a_pos'], 2, gl.FLOAT, false, 2 * 4, base_lod_points_offset + level.vertices_offset, 4);
gl.drawElementsInstanced(gl.TRIANGLES, level.data.indices.size, gl.UNSIGNED_INT, level.indices_offset, batch_size);
}
}
// I don't really know why I need to do this, but it
// makes background patter drawcall work properly
gl.vertexAttribDivisor(pr.locations['a_pos'], 0);
gl.vertexAttribDivisor(pr.locations['a_a'], 0);
gl.vertexAttribDivisor(pr.locations['a_b'], 0);
gl.vertexAttribDivisor(pr.locations['a_stroke_id'], 0);
gl.vertexAttribDivisor(pr.locations['a_pressure'], 0);
tv_pop(batches_tv);
tv_pop(batches_tv);
}
async function draw(state, context, animate, ts) {
const dt = ts - context.last_frame_ts;
const cpu_before = performance.now();
@ -291,173 +404,40 @@ async function draw(state, context, animate, ts) { @@ -291,173 +404,40 @@ async function draw(state, context, animate, ts) {
// "Static" data upload
if (segment_count > 0) {
const pr = programs['main'];
// Last pair (lod unused) to have a proper from;to
tv_add2(context.instance_data_batches, segment_count);
tv_add2(context.instance_data_batches, -1);
gl.clear(gl.DEPTH_BUFFER_BIT); // draw strokes above the images
gl.useProgram(pr.program);
const total_static_size = context.instance_data_points.size * 4 +
context.instance_data_ids.size * 4 +
round_to_pow2(context.instance_data_pressures.size, 4) +
total_lod_floats * 4;
gl.bindBuffer(gl.ARRAY_BUFFER, buffers['b_strokes_static']);
gl.bufferData(gl.ARRAY_BUFFER, total_static_size, gl.STREAM_DRAW);
// Segment points, segment stroke ids, segment pressures
gl.bufferSubData(gl.ARRAY_BUFFER, 0, tv_data(context.instance_data_points));
gl.bufferSubData(gl.ARRAY_BUFFER, context.instance_data_points.size * 4, tv_data(context.instance_data_ids));
gl.bufferSubData(gl.ARRAY_BUFFER, context.instance_data_points.size * 4 + context.instance_data_ids.size * 4,
tv_data(context.instance_data_pressures));
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, buffers['i_strokes_static']);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, total_lod_indices * 4, gl.STREAM_DRAW);
// Upload all variants of LOD vertices/indices
const base_lod_points_offset = context.instance_data_points.size * 4 + context.instance_data_ids.size * 4 + round_to_pow2(context.instance_data_pressures.size, 4);
for (const level of lod_levels) {
gl.bufferSubData(gl.ARRAY_BUFFER, base_lod_points_offset + level.vertices_offset, tv_data(level.data.points));
gl.bufferSubData(gl.ELEMENT_ARRAY_BUFFER, level.indices_offset, tv_data(level.data.indices));
}
// Per-stroke data (base width, color)
gl.bindTexture(gl.TEXTURE_2D, textures['stroke_data']);
upload_square_rgba16ui_texture(gl, context.stroke_data, config.stroke_texture_size);
gl.uniform2f(pr.locations['u_res'], context.canvas.width, context.canvas.height);
gl.uniform2f(pr.locations['u_scale'], state.canvas.zoom, state.canvas.zoom);
gl.uniform2f(pr.locations['u_translation'], state.canvas.offset.x, state.canvas.offset.y);
gl.uniform1i(pr.locations['u_stroke_count'], state.events.length);
gl.uniform1i(pr.locations['u_debug_mode'], state.debug.red);
gl.uniform1i(pr.locations['u_stroke_data'], 0);
gl.uniform1i(pr.locations['u_stroke_texture_size'], config.stroke_texture_size);
gl.enableVertexAttribArray(pr.locations['a_pos']);
gl.enableVertexAttribArray(pr.locations['a_a']);
gl.enableVertexAttribArray(pr.locations['a_b']);
gl.enableVertexAttribArray(pr.locations['a_stroke_id']);
gl.enableVertexAttribArray(pr.locations['a_pressure']);
gl.vertexAttribDivisor(pr.locations['a_pos'], 0);
gl.vertexAttribDivisor(pr.locations['a_a'], 1);
gl.vertexAttribDivisor(pr.locations['a_b'], 1);
gl.vertexAttribDivisor(pr.locations['a_stroke_id'], 1);
gl.vertexAttribDivisor(pr.locations['a_pressure'], 1);
for (let b = 0; b < context.instance_data_batches.size - 2; b += 2) {
const batch_from = context.instance_data_batches.data[b + 0];
const batch_size = context.instance_data_batches.data[b + 2] - batch_from;
let lod_level = context.instance_data_batches.data[b + 1];
if (config.debug_force_lod !== null) {
lod_level = config.debug_force_lod;
}
const level = lod_levels[lod_level];
if (batch_size > 0) {
stat_total_vertices += batch_size * level.data.indices.size;
gl.uniform1i(pr.locations['u_circle_points'], level.data.points.size / 2 - 4);
gl.uniform3f(pr.locations['u_debug_color'],
(lod_level * 785892 + 125127) % 8 / 7,
(lod_level * 901824 + 985835) % 8 / 7,
(lod_level * 232181 + 838533) % 8 / 7,
);
// Points (a, b) and stroke ids are stored in separate cpu buffers so that points can be reused (look at stride and offset values)
gl.vertexAttribPointer(pr.locations['a_a'], 2, gl.FLOAT, false, 2 * 4, batch_from * 2 * 4);
gl.vertexAttribPointer(pr.locations['a_b'], 2, gl.FLOAT, false, 2 * 4, batch_from * 2 * 4 + 2 * 4);
gl.vertexAttribIPointer(pr.locations['a_stroke_id'], 1, gl.INT, 4, context.instance_data_points.size * 4 + batch_from * 4);
gl.vertexAttribPointer(pr.locations['a_pressure'], 2, gl.UNSIGNED_BYTE, true, 1, context.instance_data_points.size * 4 + context.instance_data_ids.size * 4 + batch_from);
gl.vertexAttribPointer(pr.locations['a_pos'], 2, gl.FLOAT, false, 2 * 4, base_lod_points_offset + level.vertices_offset, 4);
gl.drawElementsInstanced(gl.TRIANGLES, level.data.indices.size, gl.UNSIGNED_INT, level.indices_offset, batch_size);
}
}
// I don't really know why I need to do this, but it
// makes background patter drawcall work properly
gl.vertexAttribDivisor(pr.locations['a_pos'], 0);
gl.vertexAttribDivisor(pr.locations['a_a'], 0);
gl.vertexAttribDivisor(pr.locations['a_b'], 0);
gl.vertexAttribDivisor(pr.locations['a_stroke_id'], 0);
gl.vertexAttribDivisor(pr.locations['a_pressure'], 0);
draw_strokes(state, context.canvas.width, context.canvas.height, programs, gl, lod_levels, segment_count,
total_lod_floats,
total_lod_indices,
context.instance_data_batches,
context.instance_data_points,
context.instance_data_ids,
context.instance_data_pressures,
buffers['b_strokes_static'],
buffers['i_strokes_static'],
textures['stroke_data'],
context.stroke_data,
state.events.length, // not really
);
}
// Dynamic draw (strokes currently being drawn)
if (false && dynamic_segment_count > 0) {
const pr = programs['main']; // same as static
if (dynamic_segment_count > 0) {
// Dynamic strokes should be drawn above static strokes
gl.clear(gl.DEPTH_BUFFER_BIT);
gl.useProgram(pr.program);
gl.uniform1i(pr.locations['u_stroke_count'], dynamic_stroke_count);
gl.uniform1i(pr.locations['u_stroke_data'], 0);
gl.uniform1i(pr.locations['u_stroke_texture_size'], config.dynamic_stroke_texture_size);
gl.bindBuffer(gl.ARRAY_BUFFER, buffers['b_strokes_dynamic']);
// Dynamic data upload
const total_dynamic_size =
context.dynamic_instance_points.size * 4 + context.dynamic_instance_ids.size * 4 +
context.dynamic_instance_pressure.size;
gl.bufferData(gl.ARRAY_BUFFER, total_dynamic_size, gl.STREAM_DRAW);
gl.bufferSubData(gl.ARRAY_BUFFER, 0, tv_data(context.dynamic_instance_points));
gl.bufferSubData(gl.ARRAY_BUFFER, context.dynamic_instance_points.size * 4, tv_data(context.dynamic_instance_ids));
gl.bufferSubData(gl.ARRAY_BUFFER, context.dynamic_instance_points.size * 4 + context.dynamic_instance_ids.size * 4,
tv_data(context.dynamic_instance_pressure));
gl.bindTexture(gl.TEXTURE_2D, textures['dynamic_stroke_data']);
upload_square_rgba16ui_texture(gl, context.dynamic_stroke_data, config.dynamic_stroke_texture_size);
gl.uniform2f(pr.locations['u_res'], context.canvas.width, context.canvas.height);
gl.uniform2f(pr.locations['u_scale'], state.canvas.zoom, state.canvas.zoom);
gl.uniform2f(pr.locations['u_translation'], state.canvas.offset.x, state.canvas.offset.y);
gl.uniform1i(pr.locations['u_stroke_count'], context.dynamic_stroke_count);
gl.uniform1i(pr.locations['u_debug_mode'], state.debug.red);
gl.uniform1i(pr.locations['u_stroke_data'], 0);
gl.uniform1i(pr.locations['u_stroke_texture_size'], config.dynamic_stroke_texture_size);
gl.enableVertexAttribArray(pr.locations['a_a']);
gl.enableVertexAttribArray(pr.locations['a_b']);
gl.enableVertexAttribArray(pr.locations['a_stroke_id']);
gl.enableVertexAttribArray(pr.locations['a_pressure']);
// Points (a, b) and stroke ids are stored in separate cpu buffers so that points can be reused (look at stride and offset values)
if (context.dynamic_instance_ids.size > 1) {
gl.vertexAttribPointer(pr.locations['a_a'], 2, gl.FLOAT, false, 2 * 4, 0);
gl.vertexAttribPointer(pr.locations['a_b'], 2, gl.FLOAT, false, 2 * 4, 2 * 4);
} else {
// A special case where there is no second point. Reuse the first point and handle the zero length segment in the shader
gl.vertexAttribPointer(pr.locations['a_a'], 2, gl.FLOAT, false, 2 * 4, 0);
gl.vertexAttribPointer(pr.locations['a_b'], 2, gl.FLOAT, false, 2 * 4, 0);
}
gl.vertexAttribIPointer(pr.locations['a_stroke_id'], 1, gl.INT, 4, context.dynamic_instance_points.size * 4);
gl.vertexAttribPointer(pr.locations['a_pressure'], 2, gl.UNSIGNED_BYTE, true, 1, context.dynamic_instance_points.size * 4 + context.dynamic_instance_ids.size * 4);
gl.vertexAttribDivisor(pr.locations['a_a'], 1);
gl.vertexAttribDivisor(pr.locations['a_b'], 1);
gl.vertexAttribDivisor(pr.locations['a_stroke_id'], 1);
gl.vertexAttribDivisor(pr.locations['a_pressure'], 1);
gl.drawArraysInstanced(gl.TRIANGLES, 0, 32 * 3 + 6 + 32 * 3, dynamic_segment_count);
gl.vertexAttribDivisor(pr.locations['a_a'], 0);
gl.vertexAttribDivisor(pr.locations['a_b'], 0);
gl.vertexAttribDivisor(pr.locations['a_stroke_id'], 0);
gl.vertexAttribDivisor(pr.locations['a_pressure'], 0);
draw_strokes(state, context.canvas.width, context.canvas.height, programs, gl, lod_levels, dynamic_segment_count,
total_lod_floats,
total_lod_indices,
context.dynamic_instance_batches,
context.dynamic_instance_points,
context.dynamic_instance_ids,
context.dynamic_instance_pressure,
buffers['b_strokes_dynamic'],
buffers['i_strokes_dynamic'],
textures['dynamic_stroke_data'],
context.dynamic_stroke_data,
context.dynamic_stroke_count,
);
}
// HUD: resize handles, etc

45
client/webgl_geometry.js

@ -84,15 +84,19 @@ function recompute_dynamic_data(state, context) { @@ -84,15 +84,19 @@ function recompute_dynamic_data(state, context) {
tv_ensure(context.dynamic_instance_points, round_to_pow2(total_points * 2, 4096));
tv_ensure(context.dynamic_instance_pressure, round_to_pow2(total_points, 4096));
tv_ensure(context.dynamic_instance_ids, round_to_pow2(total_points, 4096));
tv_ensure(context.dynamic_instance_batches, round_to_pow2(total_strokes * 2, 4096));
tv_clear(context.dynamic_instance_points);
tv_clear(context.dynamic_instance_pressure);
tv_clear(context.dynamic_instance_ids);
tv_clear(context.dynamic_instance_batches);
context.dynamic_stroke_data = ser_ensure(context.dynamic_stroke_data, config.bytes_per_stroke * total_strokes);
ser_clear(context.dynamic_stroke_data);
let stroke_index = 0;
let last_lod = -100;
let batch_size = 0;
for (const player_id in state.players) {
// player has the same data as their current stroke: points, color, width
@ -124,13 +128,54 @@ function recompute_dynamic_data(state, context) { @@ -124,13 +128,54 @@ function recompute_dynamic_data(state, context) {
ser_u16(context.dynamic_stroke_data, b);
ser_u16(context.dynamic_stroke_data, stroke.width);
let lod;
const perceptual_width = stroke.width * state.canvas.zoom;
// Copypaste from the WASM version @lod
if (perceptual_width < 1.9) {
lod = 0;
} else if (perceptual_width < 4.56) {
lod = 1;
} else if (perceptual_width < 6.12) {
lod = 2;
} else if (perceptual_width < 25.08) {
lod = 3;
} else if (perceptual_width < 122.74) {
lod = 4;
} else if (perceptual_width < 1710.0) {
lod = 5;
} else {
lod = 6;
}
if (batch_size > 0 && lod !== last_lod) {
tv_add(context.dynamic_instance_batches, batch_size);
tv_add(context.dynamic_instance_batches, last_lod);
batch_size = 0;
}
batch_size += stroke.points.length;
last_lod = lod;
stroke_index += 1; // TODO: proper player Z order
}
}
}
if (batch_size > 0) {
tv_add(context.dynamic_instance_batches, batch_size);
tv_add(context.dynamic_instance_batches, last_lod);
}
context.dynamic_segment_count = total_points;
context.dynamic_stroke_count = total_strokes;
let batch_base = 0;
for (let i = 0; i < context.dynamic_instance_batches.size; i += 2) {
const nbatches = context.dynamic_instance_batches.data[i * 2 + 0];
context.dynamic_instance_batches.data[i * 2 + 0] = batch_base;
batch_base += nbatches;
}
}
function geometry_start_prestroke(state, player_id) {

5
client/webgl_shaders.js

@ -323,8 +323,10 @@ function init_webgl(state, context) { @@ -323,8 +323,10 @@ function init_webgl(state, context) {
gl.enable(gl.BLEND);
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
/*
gl.enable(gl.DEPTH_TEST);
gl.depthFunc(gl.NOTEQUAL);
*/
context.gpu_timer_ext = gl.getExtension('EXT_disjoint_timer_query_webgl2');
if (context.gpu_timer_ext === null) {
@ -358,6 +360,7 @@ function init_webgl(state, context) { @@ -358,6 +360,7 @@ function init_webgl(state, context) {
'b_strokes_static': gl.createBuffer(),
'i_strokes_static': gl.createBuffer(),
'b_strokes_dynamic': gl.createBuffer(),
'i_strokes_dynamic': gl.createBuffer(),
'b_instance_dot': gl.createBuffer(),
'b_instance_grid': gl.createBuffer(),
'b_dot': gl.createBuffer(),
@ -379,7 +382,7 @@ function init_webgl(state, context) { @@ -379,7 +382,7 @@ function init_webgl(state, context) {
gl.bindTexture(gl.TEXTURE_2D, context.textures['dynamic_stroke_data']);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA16UI, config.dynamic_stroke_texture_size, config.dynamic_stroke_texture_size, 0, gl.RGBA_INTEGER, gl.UNSIGNED_SHORT, new Uint16Array(config.dynamic_stroke_texture_size * config.dynamic_stroke_texture_size * 4)); // fill the whole texture once with zeroes to kill a warning about a partial upload
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA16UI, config.stroke_texture_size, config.stroke_texture_size, 0, gl.RGBA_INTEGER, gl.UNSIGNED_SHORT, new Uint16Array(config.stroke_texture_size * config.stroke_texture_size * 4)); // fill the whole texture once with zeroes to kill a warning about a partial upload
gl.bindTexture(gl.TEXTURE_2D, context.textures['ui']);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);

Loading…
Cancel
Save