diff --git a/src/web_page.gleam b/src/web_page.gleam index b673812..cf58d80 100644 --- a/src/web_page.gleam +++ b/src/web_page.gleam @@ -1,20 +1,14 @@ import lustre import tiramisu import web_page/gloom -import web_page/hover -import web_page/line import web_page/scene +import web_page/terrain @external(javascript, "./web_page.ffi.mjs", "install_sound_feedback") fn install_sound_feedback() -> Nil pub fn main() -> Nil { - let extensions = [ - gloom.ext(), - hover.ext(), - line.ext(), - ..tiramisu.builtin_extensions() - ] + let extensions = [gloom.ext(), terrain.ext(), ..tiramisu.builtin_extensions()] let assert Ok(Nil) = tiramisu.register(extensions) diff --git a/src/web_page/field.gleam b/src/web_page/field.gleam deleted file mode 100644 index 6693842..0000000 --- a/src/web_page/field.gleam +++ /dev/null @@ -1,198 +0,0 @@ -import gleam/float -import gleam/int -import vec/vec2 -import vec/vec3 - -pub type Field = - fn(Float, Float) -> Float - -pub type Point = - vec3.Vec3(Float) - -pub type Line = - List(Point) - -pub type MapPoint = - vec2.Vec2(Float) - -pub fn new_perlin( - seed seed: Int, - frequency frequency: Float, - amplitude amplitude: Float, -) -> fn(Float, Float) -> Float { - fn(x, y) { - let frequency = float.max(frequency, 0.001) - let x = x *. frequency - let y = y *. frequency - - perlin_unit(seed, x, y) *. amplitude - } -} - -pub fn new_octave_perlin( - seed seed: Int, - frequency frequency: Float, - amplitude amplitude: Float, - octaves octaves: Int, - lacunarity lacunarity: Float, - persistence persistence: Float, -) -> fn(Float, Float) -> Float { - fn(x, y) { - let octaves = int.max(octaves, 1) - let frequency = float.max(frequency, 0.001) - let lacunarity = float.max(lacunarity, 1.0) - let persistence = float.clamp(persistence, min: 0.0, max: 1.0) - - let #(height, weight_total, _, _) = - int.range( - from: 0, - to: octaves, - with: #(0.0, 0.0, frequency, 1.0), - run: fn(acc, octave) { - let #(height, weight_total, octave_frequency, octave_weight) = acc - let next_height = - height - +. perlin_unit( - seed + octave * 101, - x *. octave_frequency, - y *. octave_frequency, - ) - *. octave_weight - - #( - next_height, - weight_total +. octave_weight, - octave_frequency *. lacunarity, - octave_weight *. persistence, - ) - }, - ) - - height /. weight_total *. amplitude - } -} - -fn perlin_unit(seed: Int, x: Float, y: Float) -> Float { - let x0 = floor_to_int(x) - let y0 = floor_to_int(y) - let x1 = x0 + 1 - let y1 = y0 + 1 - - let local_x = x -. int.to_float(x0) - let local_y = y -. int.to_float(y0) - let fade_x = fade(local_x) - let fade_y = fade(local_y) - - let south_west = gradient_dot(seed, x0, y0, local_x, local_y) - let south_east = gradient_dot(seed, x1, y0, local_x -. 1.0, local_y) - let north_west = gradient_dot(seed, x0, y1, local_x, local_y -. 1.0) - let north_east = gradient_dot(seed, x1, y1, local_x -. 1.0, local_y -. 1.0) - - let south = interpolate(south_west, south_east, fade_x) - let north = interpolate(north_west, north_east, fade_x) - interpolate(south, north, fade_y) -} - -fn gradient_dot( - seed: Int, - lattice_x: Int, - lattice_y: Int, - offset_x: Float, - offset_y: Float, -) -> Float { - case gradient_index(seed, lattice_x, lattice_y) { - 0 -> offset_x - 1 -> 0.0 -. offset_x - 2 -> offset_y - 3 -> 0.0 -. offset_y - 4 -> offset_x +. offset_y - 5 -> { 0.0 -. offset_x } +. offset_y - 6 -> offset_x -. offset_y - 7 -> { 0.0 -. offset_x } -. offset_y - _ -> 0.0 - } -} - -fn gradient_index(seed: Int, x: Int, y: Int) -> Int { - let scrambled = - permute(modulo_289(x + seed)) - |> int.add(modulo_289(y)) - |> permute - - case int.modulo(scrambled, by: 8) { - Ok(value) -> value - Error(Nil) -> 0 - } -} - -fn permute(value: Int) -> Int { - modulo_289({ value * 34 + 1 } * value) -} - -fn modulo_289(value: Int) -> Int { - case int.modulo(value, by: 289) { - Ok(value) -> value - Error(Nil) -> 0 - } -} - -fn floor_to_int(value: Float) -> Int { - value - |> float.floor - |> float.round -} - -fn fade(value: Float) -> Float { - value *. value *. value *. { value *. { value *. 6.0 -. 15.0 } +. 10.0 } -} - -fn interpolate(from: Float, to: Float, amount: Float) -> Float { - from +. { to -. from } *. amount -} - -pub fn sample( - field: Field, - from start: MapPoint, - to finish: MapPoint, - samples samples: Int, -) -> Line { - let samples = int.max(samples, 2) - let last = samples - 1 - - int.range(from: 0, to: samples, with: [], run: fn(points, index) { - let amount = int.to_float(index) /. int.to_float(last) - - let x = start.x +. { finish.x -. start.x } *. amount - let y = start.y +. { finish.y -. start.y } *. amount - let z = field(x, y) - - [vec3.Vec3(x:, y:, z:), ..points] - }) -} - -pub fn rows( - field: Field, - y_from y_from: Float, - y_to y_to: Float, - row_count row_count: Int, - x_from x_from: Float, - x_to x_to: Float, - samples samples: Int, -) -> List(Line) { - let row_count = int.max(row_count, 1) - let last = int.max(row_count - 1, 1) - - int.range(from: 0, to: row_count, with: [], run: fn(lines, row) { - let amount = int.to_float(row) /. int.to_float(last) - let y = y_from +. { y_to -. y_from } *. amount - [ - sample( - field, - from: vec2.Vec2(x: x_from, y:), - to: vec2.Vec2(x: x_to, y:), - samples:, - ), - ..lines - ] - }) -} diff --git a/src/web_page/hover.ffi.mjs b/src/web_page/hover.ffi.mjs deleted file mode 100644 index 57493fb..0000000 --- a/src/web_page/hover.ffi.mjs +++ /dev/null @@ -1,132 +0,0 @@ -import * as THREE from "three"; - -const installations = new WeakMap(); -const map_extent = 10; - -export function install(canvas, camera, terrain) { - const existing = installations.get(canvas); - if (existing) { - existing.camera = camera; - existing.terrain = terrain; - return; - } - - const root = canvas.getRootNode(); - const host = root instanceof ShadowRoot ? root.host : canvas; - const abort_controller = new AbortController(); - const raycaster = new THREE.Raycaster(); - const pointer = new THREE.Vector2(); - const plane = new THREE.Plane(); - const plane_normal = new THREE.Vector3(); - const plane_origin = new THREE.Vector3(); - const world_point = new THREE.Vector3(); - const local_point = new THREE.Vector3(); - const state = { - abort_controller, - animation_frame: 0, - camera, - hovering: false, - pointer_x: 0, - pointer_y: 0, - terrain, - }; - - function leave() { - if (!state.hovering) return; - state.hovering = false; - host.dispatchEvent(new CustomEvent("terrain:leave")); - } - - function project_pointer() { - state.animation_frame = 0; - - const bounds = canvas.getBoundingClientRect(); - if ( - bounds.width <= 0 || - bounds.height <= 0 || - state.pointer_x < bounds.left || - state.pointer_x > bounds.right || - state.pointer_y < bounds.top || - state.pointer_y > bounds.bottom - ) { - leave(); - return; - } - - pointer.set( - ((state.pointer_x - bounds.left) / bounds.width) * 2 - 1, - 1 - ((state.pointer_y - bounds.top) / bounds.height) * 2, - ); - - state.camera.updateMatrixWorld(true); - state.terrain.updateMatrixWorld(true); - raycaster.setFromCamera(pointer, state.camera); - - plane_origin.set(0, 0, 0).applyMatrix4(state.terrain.matrixWorld); - plane_normal - .set(0, 1, 0) - .transformDirection(state.terrain.matrixWorld); - plane.setFromNormalAndCoplanarPoint(plane_normal, plane_origin); - - if (!raycaster.ray.intersectPlane(plane, world_point)) { - leave(); - return; - } - - local_point.copy(world_point); - state.terrain.worldToLocal(local_point); - - if ( - Math.abs(local_point.x) > map_extent || - Math.abs(local_point.z) > map_extent - ) { - leave(); - return; - } - - state.hovering = true; - host.dispatchEvent( - new CustomEvent("terrain:hover", { - detail: { x: local_point.x, y: local_point.z }, - }), - ); - } - - function on_pointer_move(event) { - if (event.pointerType !== "mouse") { - leave(); - return; - } - - state.pointer_x = event.clientX; - state.pointer_y = event.clientY; - if (!state.animation_frame) { - state.animation_frame = requestAnimationFrame(project_pointer); - } - } - - document.addEventListener("pointermove", on_pointer_move, { - capture: true, - passive: true, - signal: abort_controller.signal, - }); - document.addEventListener( - "pointerout", - (event) => { - if (event.relatedTarget === null) leave(); - }, - { capture: true, passive: true, signal: abort_controller.signal }, - ); - window.addEventListener("blur", leave, { signal: abort_controller.signal }); - - installations.set(canvas, state); -} - -export function uninstall(canvas) { - const state = installations.get(canvas); - if (!state) return; - - state.abort_controller.abort(); - if (state.animation_frame) cancelAnimationFrame(state.animation_frame); - installations.delete(canvas); -} diff --git a/src/web_page/hover.gleam b/src/web_page/hover.gleam deleted file mode 100644 index 50e5c8c..0000000 --- a/src/web_page/hover.gleam +++ /dev/null @@ -1,113 +0,0 @@ -import gleam/dict.{type Dict} -import gleam/dynamic/decode -import gleam/option.{type Option} -import lustre/attribute.{type Attribute} -import lustre/effect -import lustre/element -import lustre/event -import savoiardi/camera.{type Camera} -import savoiardi/object.{type Object3D} -import savoiardi/renderer as three_renderer -import tiramisu/dev/extension -import tiramisu/dev/runtime.{type Runtime} - -pub const tag: String = "tiramisu-terrain-hover" - -pub fn hover( - id: String, - attributes: List(Attribute(msg)), - children: List(element.Element(msg)), -) -> element.Element(msg) { - element.element(tag, [attribute.id(id), ..attributes], children) -} - -pub fn on_move(to_message: fn(Float, Float) -> msg) -> Attribute(msg) { - event.on("terrain:hover", { - use x <- decode.subfield(["detail", "x"], decode.float) - use y <- decode.subfield(["detail", "y"], decode.float) - decode.success(to_message(x, y)) - }) -} - -pub fn on_leave(message: msg) -> Attribute(msg) { - event.on("terrain:leave", decode.success(message)) -} - -fn create( - runtime: Runtime, - _id: String, - parent_id: String, - _attributes: Dict(String, String), -) -> #(Runtime, effect.Effect(extension.Msg)) { - #(runtime, install_effect(runtime, parent_id)) -} - -fn update( - runtime: Runtime, - _id: String, - _parent_id: String, - _object: Option(Object3D), - _attributes: Dict(String, String), - _changed_attributes: extension.AttributeChanges, -) -> #(Runtime, effect.Effect(extension.Msg)) { - #(runtime, effect.none()) -} - -fn remove( - runtime: Runtime, - _id: String, - _parent_id: String, - _object: Object3D, -) -> #(Runtime, effect.Effect(extension.Msg)) { - #(runtime, uninstall_effect(runtime)) -} - -fn install_effect( - runtime: Runtime, - terrain_id: String, -) -> effect.Effect(extension.Msg) { - case runtime.object(runtime, terrain_id), runtime.active_camera(runtime) { - Ok(terrain), Ok(camera) -> - effect.from(fn(_) { - install( - three_renderer.canvas(runtime.threejs_renderer(runtime)), - camera, - terrain, - ) - Nil - }) - Error(Nil), _ -> effect.none() - _, Error(Nil) -> effect.none() - } -} - -fn uninstall_effect(runtime: Runtime) -> effect.Effect(extension.Msg) { - effect.from(fn(_) { - uninstall(three_renderer.canvas(runtime.threejs_renderer(runtime))) - Nil - }) -} - -fn observed_attributes() -> List(String) { - [] -} - -pub fn ext() -> extension.Extension { - extension.node_extension( - tag:, - observed_attributes: observed_attributes(), - create:, - update:, - remove:, - ) -} - -@external(javascript, "./hover.ffi.mjs", "install") -fn install( - canvas: three_renderer.Canvas, - camera: Camera, - terrain: Object3D, -) -> Nil - -@external(javascript, "./hover.ffi.mjs", "uninstall") -fn uninstall(canvas: three_renderer.Canvas) -> Nil diff --git a/src/web_page/line.gleam b/src/web_page/line.gleam deleted file mode 100644 index 684a812..0000000 --- a/src/web_page/line.gleam +++ /dev/null @@ -1,204 +0,0 @@ -import gleam/dict.{type Dict} -import gleam/float -import gleam/int -import gleam/list -import gleam/option -import gleam/result -import gleam/string -import lustre/attribute.{type Attribute} -import lustre/effect -import lustre/element.{type Element} -import savoiardi/geometry -import savoiardi/material -import savoiardi/object.{type Object3D} -import tiramisu/dev/extension -import tiramisu/dev/runtime.{type Runtime} -import vec/vec3 -import web_page/field - -pub const tag: String = "tiramisu-line" - -pub fn line( - id: String, - attributes: List(Attribute(msg)), - children: List(Element(msg)), -) -> Element(msg) { - element.element(tag, [attribute.id(id), ..attributes], children) -} - -pub fn points(value: field.Line) -> Attribute(msg) { - attribute.attribute("points", serialize_line(value)) -} - -pub fn color(value: Int) -> Attribute(msg) { - attribute.attribute("color", int.to_string(value)) -} - -pub fn opacity(value: Float) -> Attribute(msg) { - attribute.attribute("opacity", float.to_string(value)) -} - -pub fn line_width(value: Float) -> Attribute(msg) { - attribute.attribute("line-width", float.to_string(value)) -} - -fn create( - runtime: Runtime, - id: String, - parent_id: String, - attributes: Dict(String, String), -) -> #(Runtime, effect.Effect(extension.Msg)) { - let object = build_line(attributes) - let next_runtime = runtime.add_object(runtime, id, object:, parent_id:, tag:) - #(next_runtime, effect.none()) -} - -fn update( - runtime: Runtime, - id: String, - _parent_id: String, - object: option.Option(Object3D), - attributes: Dict(String, String), - changed_attributes: extension.AttributeChanges, -) -> #(Runtime, effect.Effect(extension.Msg)) { - case object, has_line_change(changed_attributes) { - option.Some(_), True -> { - let next = build_line(attributes) - #(runtime.replace_object(runtime, id, next), effect.none()) - } - _, _ -> #(runtime, effect.none()) - } -} - -fn remove( - runtime: Runtime, - id: String, - parent_id: String, - object: Object3D, -) -> #(Runtime, effect.Effect(extension.Msg)) { - let next_runtime = runtime.remove_object(runtime, id, parent_id, object) - #(next_runtime, effect.none()) -} - -fn build_line(attributes: Dict(String, String)) -> Object3D { - let line_color = get_int(attributes, "color", 0x87ff9a) - let line_opacity = get_float(attributes, "opacity", 0.9) - let line_width = get_float(attributes, "line-width", 1.0) - let serialized_points = - dict.get(attributes, "points") - |> result.unwrap("") - - parsed_line_to_object( - parse_line(serialized_points), - line_color, - line_opacity, - line_width, - ) -} - -fn parsed_line_to_object( - parsed_line: field.Line, - color: Int, - opacity: Float, - line_width: Float, -) -> Object3D { - let points = - parsed_line - |> segment_points - |> list.map(fn(point) { vec3.Vec3(point.x, point.z, point.y) }) - - let options = - material.LineBasicOptions( - ..material.line_basic_options(), - color: color, - transparent: True, - opacity: opacity, - linewidth: line_width, - ) - - object.line_segments( - geometry.line_points(points), - material.line_basic(options), - ) -} - -fn segment_points(points: List(field.Point)) -> List(field.Point) { - case points { - [first, second, ..rest] -> - segment_points_loop(rest, second, [second, first]) - |> list.reverse - _ -> points - } -} - -fn segment_points_loop( - remaining: List(field.Point), - previous: field.Point, - acc: List(field.Point), -) -> List(field.Point) { - case remaining { - [next, ..rest] -> segment_points_loop(rest, next, [next, previous, ..acc]) - [] -> acc - } -} - -fn parse_line(value: String) -> field.Line { - value - |> string.split(";") - |> list.filter_map(parse_point) -} - -fn parse_point(value: String) -> Result(field.Point, Nil) { - case value |> string.split(",") |> list.map(string.trim) { - [x, y, z] -> { - use x <- result.try(float.parse(x)) - use y <- result.try(float.parse(y)) - use z <- result.try(float.parse(z)) - Ok(vec3.Vec3(x:, y:, z:)) - } - _ -> Error(Nil) - } -} - -fn get_int(attributes: Dict(String, String), key: String, default: Int) -> Int { - extension.get(attributes, key, default, int.parse) -} - -fn get_float( - attributes: Dict(String, String), - key: String, - default: Float, -) -> Float { - extension.get(attributes, key, default, float.parse) -} - -fn has_line_change(changes: extension.AttributeChanges) -> Bool { - extension.has_any_change(changes, observed_attributes()) -} - -fn observed_attributes() -> List(String) { - ["points", "color", "opacity", "line-width"] -} - -pub fn ext() -> extension.Extension { - extension.node_extension( - tag:, - observed_attributes: observed_attributes(), - create:, - update:, - remove:, - ) -} - -fn serialize_line(line: List(vec3.Vec3(Float))) -> String { - list.map(line, serialize_point) - |> string.join(with: ";") -} - -fn serialize_point(point: vec3.Vec3(Float)) -> String { - float.to_string(point.x) - <> "," - <> float.to_string(point.y) - <> "," - <> float.to_string(point.z) -} diff --git a/src/web_page/scene.gleam b/src/web_page/scene.gleam index fa74a09..af7ab23 100644 --- a/src/web_page/scene.gleam +++ b/src/web_page/scene.gleam @@ -1,9 +1,6 @@ -import ease -import gleam/float import gleam/int import gleam/list import gleam/string -import gleam/time/duration import gleam_community/maths import lustre/attribute import lustre/effect @@ -13,29 +10,11 @@ import lustre/event import prng/random import tiramisu import tiramisu/camera -import tiramisu/material -import tiramisu/primitive -import tiramisu/renderer import tiramisu/scene import tiramisu/transform -import vec/vec2 import vec/vec3 -import web_page/field import web_page/gloom -import web_page/hover -import web_page/line - -const map_extent = 10.0 - -const spike_duration_seconds = 2.5 - -const temporal_noise_speed = 0.42 - -const temporal_noise_amplitude = 0.22 - -const hover_bump_amplitude = 0.38 - -const hover_bump_spread = 1.25 +import web_page/terrain // Randomize around the original composition without drifting under portfolio copy. const spike_origin_x = -5.0 @@ -48,28 +27,11 @@ const spike_maximum_offset = 1.0 const full_rotation = 6.283185307179586 -const graph_offset_x = 1.0 - -const graph_offset_y = -1.0 - -const graph_bottom = -2.0 - -const graph_height = 5.0 - -const graph_spine_width = 0.1 - -const graph_color = 0xff4f45 - pub type Spike { - Spike(age_seconds: Float, x: Float, y: Float) + Spike(sequence: Int, x: Float, y: Float) NoSpike } -pub type HoverBump { - Hovering(x: Float, y: Float) - NoHover -} - pub type PortfolioPanel { About Work @@ -89,19 +51,13 @@ pub type Model { Model( seed: Int, spike_seed: random.Seed, - time: duration.Duration, spike: Spike, - hover_bump: HoverBump, - lines: List(List(vec3.Vec3(Float))), open_panel: PortfolioPanel, ) } pub type Msg { - Tick(renderer.Tick) OpenPanel(PortfolioPanel) - TerrainHovered(x: Float, y: Float) - TerrainLeft } pub fn init(_flags: Nil) -> #(Model, effect.Effect(Msg)) { @@ -111,10 +67,7 @@ pub fn init(_flags: Nil) -> #(Model, effect.Effect(Msg)) { Model( seed:, spike_seed: random.new_seed(seed + 104_729), - time: duration.seconds(0), spike: NoSpike, - hover_bump: NoHover, - lines: [], open_panel: About, ), effect.none(), @@ -123,40 +76,6 @@ pub fn init(_flags: Nil) -> #(Model, effect.Effect(Msg)) { pub fn update(model: Model, msg: Msg) -> #(Model, effect.Effect(Msg)) { case msg { - Tick(tick) -> { - let time = duration.add(tick.delta_time, model.time) - let spike = advance_spike(model.spike, tick.delta_time) - - let base_field = - field.new_octave_perlin( - seed: model.seed, - frequency: 0.2, - amplitude: 1.5, - octaves: 3, - lacunarity: 1.5, - persistence: 3.0, - ) - - let active_field = - base_field - |> add_temporal_noise(model.seed, duration.to_seconds(time)) - |> spiked_field(model.spike) - |> hover_bumped_field(model.hover_bump) - - let lines = - field.rows( - active_field, - y_from: 0.0 -. map_extent, - y_to: map_extent, - row_count: 70, - x_from: 0.0 -. map_extent, - x_to: map_extent, - samples: 50, - ) - - #(Model(..model, time:, spike:, lines:), effect.none()) - } - OpenPanel(panel) -> { let #(angle, spike_seed) = random.step(random.float(0.0, full_rotation), model.spike_seed) @@ -168,56 +87,31 @@ pub fn update(model: Model, msg: Msg) -> #(Model, effect.Effect(Msg)) { let x = spike_origin_x +. maths.cos(angle) *. offset let y = spike_origin_y +. maths.sin(angle) *. offset + let sequence = case model.spike { + NoSpike -> 1 + Spike(sequence:, ..) -> sequence + 1 + } + #( Model( ..model, spike_seed:, - spike: Spike(age_seconds: 0.0, x:, y:), + spike: Spike(sequence:, x:, y:), open_panel: panel, ), effect.none(), ) } - - TerrainHovered(x:, y:) -> #( - Model(..model, hover_bump: Hovering(x:, y:)), - effect.none(), - ) - - TerrainLeft -> #(Model(..model, hover_bump: NoHover), effect.none()) } } pub fn view(model: Model) -> Element(Msg) { - let terrain_lines = - model.lines - |> list.index_map(fn(map_line, index) { - line.line( - "green-noise-line-" <> int.to_string(index), - [ - line.points(map_line), - line.color(0x87ff9a), - ], - [], - ) - }) - - let show_graph = - { - model.time - |> duration.to_seconds() - |> float.multiply(1000.0) - |> float.round() - } - % 2 - == 0 - - let terrain_children = case model.spike { - Spike(age_seconds:, x:, y:) - if age_seconds >. 0.1 && age_seconds <. 1.9 && show_graph - -> list.append(terrain_lines, graph_bar_lines(x, y)) - Spike(..) -> terrain_lines - NoSpike -> terrain_lines + let terrain_attributes = case model.spike { + NoSpike -> [terrain.seed(model.seed)] + Spike(sequence:, x:, y:) -> [ + terrain.seed(model.seed), + ..terrain.spike(sequence, x, y) + ] } html.div( @@ -230,9 +124,6 @@ pub fn view(model: Model) -> Element(Msg) { tiramisu.renderer( "primary-renderer", [ - renderer.on_tick(Tick), - hover.on_move(fn(x, y) { TerrainHovered(x:, y:) }), - hover.on_leave(TerrainLeft), attribute.class("renderer"), ], [ @@ -269,7 +160,7 @@ pub fn view(model: Model) -> Element(Msg) { [ transform.rotation(vec3.Vec3(0.0, 0.8, 0.0)), ], - [hover.hover("terrain-hover", [], []), ..terrain_children], + [terrain.terrain("terrain", terrain_attributes)], ), ], ), @@ -899,182 +790,6 @@ fn classes(groups: List(String)) -> String { string.join(groups, with: " ") } -fn graph_bar_lines(spike_x: Float, spike_y: Float) -> List(Element(Msg)) { - [ - tiramisu.primitive( - "graph-bar", - [ - primitive.plane(vec2.Vec2(graph_spine_width, graph_height)), - material.basic(), - material.color(hex: graph_color), - material.emissive(graph_color), - material.emissive_intensity(0.9), - material.transparent(True), - material.opacity(1.0), - material.side(material.Double), - transform.position(vec3.Vec3( - x: spike_x +. graph_offset_x, - y: graph_bottom +. graph_height /. 2.0, - z: spike_y +. graph_offset_y, - )), - transform.rotation(vec3.Vec3(0.0, 1.57, 0.0)), - ], - list.flatten([ - graph_ticks("graph-major-tick", every: 1.0, length: 0.4, width: 0.13), - graph_ticks("graph-minor-tick", every: 0.1, length: 0.1, width: 0.055), - ]), - ), - ] -} - -fn graph_ticks( - id_prefix: String, - every every: Float, - length length: Float, - width width: Float, -) -> List(Element(Msg)) { - int.range( - from: 0, - to: tick_count(graph_height, every), - with: [], - run: fn(ticks, index) { - let y = 0.0 -. graph_height /. 2.0 +. int.to_float(index) *. every - [ - graph_tick(id_prefix <> "-" <> int.to_string(index), y, length, width), - ..ticks - ] - }, - ) -} - -fn tick_count(height: Float, every: Float) -> Int { - height /. float.max(every, 0.001) - |> float.floor - |> float.round - |> int.add(1) -} - -fn graph_tick( - id: String, - y: Float, - length: Float, - width: Float, -) -> Element(Msg) { - tiramisu.primitive( - id, - [ - primitive.plane(vec2.Vec2(length, width)), - material.basic(), - material.color(hex: graph_color), - material.emissive(graph_color), - material.emissive_intensity(0.9), - material.transparent(True), - material.opacity(0.9), - material.side(material.Double), - transform.position(vec3.Vec3( - x: graph_spine_width -. length /. 2.0 -. 0.2, - y:, - z: 0.01, - )), - ], - [], - ) -} - -fn add_temporal_noise( - base_field: field.Field, - seed: Int, - elapsed_seconds: Float, -) -> field.Field { - let noise = - field.new_octave_perlin( - seed: seed + 7919, - frequency: 0.55, - amplitude: temporal_noise_amplitude, - octaves: 2, - lacunarity: 2.0, - persistence: 0.45, - ) - - fn(x, y) { - base_field(x, y) - +. noise( - x +. elapsed_seconds *. temporal_noise_speed, - y -. elapsed_seconds *. temporal_noise_speed *. 0.63, - ) - } -} - -fn hover_bumped_field( - base_field: field.Field, - hover_bump: HoverBump, -) -> field.Field { - case hover_bump { - NoHover -> base_field - Hovering(x: center_x, y: center_y) -> fn(x, y) { - let offset_x = x -. center_x - let offset_y = y -. center_y - let distance_squared = offset_x *. offset_x +. offset_y *. offset_y - let bump = - float.exponential(0.0 -. distance_squared /. hover_bump_spread) - *. hover_bump_amplitude - - base_field(x, y) +. bump - } - } -} - -fn advance_spike(spike: Spike, delta_time: duration.Duration) -> Spike { - case spike { - NoSpike -> NoSpike - Spike(age_seconds:, x:, y:) -> { - let age_seconds = age_seconds +. duration.to_seconds(delta_time) - case age_seconds >=. spike_duration_seconds { - True -> NoSpike - False -> Spike(age_seconds:, x:, y:) - } - } - } -} - -fn spiked_field(base_field: field.Field, spike: Spike) -> field.Field { - case spike { - NoSpike -> fn(x, y) { base_field(x, y) +. basic_shape(x, y) } - Spike(age_seconds:, x: center_x, y: center_y) -> fn(x, y) { - base_field(x, y) - +. spike_height(x -. center_x, y -. center_y, age_seconds) - +. basic_shape(x, y) - } - } -} - -fn basic_shape(x, y) { - let y = y *. 0.8 - let distance_squared = x *. x +. y *. y - case distance_squared <. 30.0 { - True -> distance_squared *. 0.07 -. 2.1 - False -> 0.0 - } -} - -fn spike_height(x: Float, y: Float, age_seconds: Float) -> Float { - let distance_squared = x *. x +. y *. y /. 3.0 - let progress = - age_seconds /. spike_duration_seconds - |> float.clamp(min: 0.0, max: 1.0) - - let spike = float.exponential(0.0 -. distance_squared /. 0.1) *. 7.0 - - spike *. ease(progress) -} - -fn ease(t) { - case t <. 0.5 { - True -> ease.reverse(ease.exponential)(10.0 *. t) - False -> 1.0 -. ease.exponential(2.0 *. t -. 1.0) - } -} - fn hud() -> Element(Msg) { html.div([attribute.class("hud")], [ html.div([attribute.class("corner top-8 left-8")], []), diff --git a/src/web_page/terrain.ffi.mjs b/src/web_page/terrain.ffi.mjs new file mode 100644 index 0000000..96fd50c --- /dev/null +++ b/src/web_page/terrain.ffi.mjs @@ -0,0 +1,438 @@ +import * as THREE from "three"; + +const controllers = new WeakMap(); +const map_extent = 10; +const spike_duration_seconds = 2.5; + +const vertex_shader = ` + uniform float u_time; + uniform float u_seed; + uniform float u_hover_active; + uniform vec2 u_hover; + uniform float u_spike_active; + uniform vec2 u_spike; + uniform float u_spike_progress; + + float modulo_289(float value) { + return mod(mod(value, 289.0) + 289.0, 289.0); + } + + float permute(float value) { + return modulo_289((value * 34.0 + 1.0) * value); + } + + float gradient_index(float seed, float x, float y) { + float scrambled = permute(modulo_289(x + seed)); + scrambled = permute(scrambled + modulo_289(y)); + return mod(scrambled, 8.0); + } + + float gradient_dot( + float seed, + float lattice_x, + float lattice_y, + float offset_x, + float offset_y + ) { + float index = gradient_index(seed, lattice_x, lattice_y); + if (index < 0.5) return offset_x; + if (index < 1.5) return -offset_x; + if (index < 2.5) return offset_y; + if (index < 3.5) return -offset_y; + if (index < 4.5) return offset_x + offset_y; + if (index < 5.5) return -offset_x + offset_y; + if (index < 6.5) return offset_x - offset_y; + return -offset_x - offset_y; + } + + float fade(float value) { + return value * value * value * (value * (value * 6.0 - 15.0) + 10.0); + } + + float perlin(float seed, vec2 point) { + vec2 lower = floor(point); + vec2 local = point - lower; + vec2 faded = vec2(fade(local.x), fade(local.y)); + + float south_west = gradient_dot( + seed, lower.x, lower.y, local.x, local.y + ); + float south_east = gradient_dot( + seed, lower.x + 1.0, lower.y, local.x - 1.0, local.y + ); + float north_west = gradient_dot( + seed, lower.x, lower.y + 1.0, local.x, local.y - 1.0 + ); + float north_east = gradient_dot( + seed, + lower.x + 1.0, + lower.y + 1.0, + local.x - 1.0, + local.y - 1.0 + ); + + float south = mix(south_west, south_east, faded.x); + float north = mix(north_west, north_east, faded.x); + return mix(south, north, faded.y); + } + + float base_noise(vec2 point) { + float noise = perlin(u_seed, point * 0.2); + noise += perlin(u_seed + 101.0, point * 0.3); + noise += perlin(u_seed + 202.0, point * 0.45); + return noise * 0.5; + } + + float temporal_noise(vec2 point) { + vec2 moving_point = point + vec2(u_time * 0.42, -u_time * 0.2646); + float noise = perlin(u_seed + 7919.0, moving_point * 0.55); + noise += perlin(u_seed + 8020.0, moving_point * 1.1) * 0.45; + return noise / 1.45 * 0.22; + } + + float basic_shape(vec2 point) { + vec2 shaped = vec2(point.x, point.y * 0.8); + float distance_squared = dot(shaped, shaped); + return distance_squared < 30.0 ? distance_squared * 0.07 - 2.1 : 0.0; + } + + float exponential_ease(float value) { + return value == 0.0 ? 0.0 : exp2(10.0 * value - 10.0); + } + + float spike_ease(float progress) { + if (progress < 0.5) { + float value = 10.0 * progress; + return 1.0 - exponential_ease(1.0 - value); + } + return 1.0 - exponential_ease(2.0 * progress - 1.0); + } + + float terrain_height(vec2 point) { + float height = base_noise(point) + temporal_noise(point) + basic_shape(point); + + if (u_hover_active > 0.5) { + vec2 offset = point - u_hover; + height += exp(-dot(offset, offset) / 1.25) * 0.38; + } + + if (u_spike_active > 0.5) { + vec2 offset = point - u_spike; + float distance_squared = offset.x * offset.x + offset.y * offset.y / 3.0; + height += exp(-distance_squared / 0.1) * 7.0 * spike_ease(u_spike_progress); + } + + return height; + } + + void main() { + vec3 transformed = position; + transformed.y = terrain_height(position.xz); + gl_Position = projectionMatrix * modelViewMatrix * vec4(transformed, 1.0); + } +`; + +const fragment_shader = ` + uniform vec3 u_color; + uniform float u_opacity; + + void main() { + gl_FragColor = vec4(u_color, u_opacity); + } +`; + +function create_terrain_positions() { + const row_count = 70; + const samples = 50; + const segment_count = row_count * (samples - 1); + const positions = new Float32Array(segment_count * 2 * 3); + let offset = 0; + + for (let row = 0; row < row_count; row += 1) { + const z = -map_extent + (row / (row_count - 1)) * map_extent * 2; + for (let sample = 0; sample < samples - 1; sample += 1) { + const x_from = -map_extent + (sample / (samples - 1)) * map_extent * 2; + const x_to = -map_extent + ((sample + 1) / (samples - 1)) * map_extent * 2; + + positions[offset++] = x_from; + positions[offset++] = 0; + positions[offset++] = z; + positions[offset++] = x_to; + positions[offset++] = 0; + positions[offset++] = z; + } + } + + return positions; +} + +function add_quad(positions, center_x, center_y, width, height, z) { + const left = center_x - width / 2; + const right = center_x + width / 2; + const bottom = center_y - height / 2; + const top = center_y + height / 2; + positions.push( + left, bottom, z, + right, bottom, z, + right, top, z, + left, bottom, z, + right, top, z, + left, top, z, + ); +} + +function create_graph() { + const positions = []; + add_quad(positions, 0, 0, 0.1, 5, 0); + + for (let index = 0; index < 6; index += 1) { + const y = -2.5 + index; + add_quad(positions, 0.1 - 0.4 / 2 - 0.2, y, 0.4, 0.13, 0.01); + } + + for (let index = 0; index < 51; index += 1) { + const y = -2.5 + index * 0.1; + add_quad(positions, 0.1 - 0.1 / 2 - 0.2, y, 0.1, 0.055, 0.01); + } + + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute( + "position", + new THREE.Float32BufferAttribute(positions, 3), + ); + const material = new THREE.MeshBasicMaterial({ + color: 0xff4f45, + opacity: 0.9, + side: THREE.DoubleSide, + transparent: true, + }); + const graph = new THREE.Mesh(geometry, material); + graph.rotation.y = Math.PI / 2; + graph.visible = false; + return graph; +} + +export function createTerrain(seed) { + const uniforms = { + u_time: { value: 0 }, + u_seed: { value: seed }, + u_hover_active: { value: 0 }, + u_hover: { value: new THREE.Vector2() }, + u_spike_active: { value: 0 }, + u_spike: { value: new THREE.Vector2() }, + u_spike_progress: { value: 0 }, + u_color: { value: new THREE.Color(0x87ff9a) }, + u_opacity: { value: 0.9 }, + }; + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute( + "position", + new THREE.BufferAttribute(create_terrain_positions(), 3), + ); + geometry.computeBoundingSphere(); + + const material = new THREE.ShaderMaterial({ + fragmentShader: fragment_shader, + transparent: true, + uniforms, + vertexShader: vertex_shader, + }); + const lines = new THREE.LineSegments(geometry, material); + const graph = create_graph(); + const terrain = new THREE.Group(); + terrain.add(lines, graph); + + controllers.set(terrain, { + animation_frame: 0, + graph, + installed: false, + last_spike_sequence: 0, + lines, + uniforms, + }); + return terrain; +} + +export function install(terrain, canvas, camera) { + const controller = controllers.get(terrain); + if (!controller) return; + if (controller.installed) { + controller.camera = camera; + return; + } + + const abort_controller = new AbortController(); + const reduced_motion = window.matchMedia("(prefers-reduced-motion: reduce)"); + const raycaster = new THREE.Raycaster(); + const pointer = new THREE.Vector2(); + const plane = new THREE.Plane(); + const plane_normal = new THREE.Vector3(); + const plane_origin = new THREE.Vector3(); + const world_point = new THREE.Vector3(); + const local_point = new THREE.Vector3(); + + Object.assign(controller, { + abort_controller, + camera, + elapsed_seconds: 0, + hovering: false, + installed: true, + last_timestamp: 0, + pointer_dirty: false, + pointer_x: 0, + pointer_y: 0, + reduced_motion, + spike_active: false, + spike_elapsed_seconds: 0, + }); + + function leave() { + controller.hovering = false; + controller.uniforms.u_hover_active.value = 0; + } + + function project_pointer() { + controller.pointer_dirty = false; + if (reduced_motion.matches) { + leave(); + return; + } + + const bounds = canvas.getBoundingClientRect(); + if ( + bounds.width <= 0 || + bounds.height <= 0 || + controller.pointer_x < bounds.left || + controller.pointer_x > bounds.right || + controller.pointer_y < bounds.top || + controller.pointer_y > bounds.bottom + ) { + leave(); + return; + } + + pointer.set( + ((controller.pointer_x - bounds.left) / bounds.width) * 2 - 1, + 1 - ((controller.pointer_y - bounds.top) / bounds.height) * 2, + ); + camera.updateMatrixWorld(true); + terrain.updateMatrixWorld(true); + raycaster.setFromCamera(pointer, camera); + plane_origin.set(0, 0, 0).applyMatrix4(terrain.matrixWorld); + plane_normal.set(0, 1, 0).transformDirection(terrain.matrixWorld); + plane.setFromNormalAndCoplanarPoint(plane_normal, plane_origin); + + if (!raycaster.ray.intersectPlane(plane, world_point)) { + leave(); + return; + } + + local_point.copy(world_point); + terrain.worldToLocal(local_point); + if ( + Math.abs(local_point.x) > map_extent || + Math.abs(local_point.z) > map_extent + ) { + leave(); + return; + } + + controller.hovering = true; + controller.uniforms.u_hover_active.value = 1; + controller.uniforms.u_hover.value.set(local_point.x, local_point.z); + } + + function animate(timestamp) { + controller.animation_frame = requestAnimationFrame(animate); + if (!controller.last_timestamp) controller.last_timestamp = timestamp; + const delta_seconds = Math.min( + Math.max((timestamp - controller.last_timestamp) / 1000, 0), + 0.05, + ); + controller.last_timestamp = timestamp; + + if (controller.pointer_dirty) project_pointer(); + + if (document.hidden || reduced_motion.matches) { + controller.uniforms.u_hover_active.value = 0; + controller.uniforms.u_spike_active.value = 0; + controller.graph.visible = false; + return; + } + + controller.elapsed_seconds += delta_seconds; + controller.uniforms.u_time.value = controller.elapsed_seconds; + + if (controller.spike_active) { + controller.spike_elapsed_seconds += delta_seconds; + const progress = controller.spike_elapsed_seconds / spike_duration_seconds; + if (progress >= 1) { + controller.spike_active = false; + controller.uniforms.u_spike_active.value = 0; + controller.graph.visible = false; + } else { + controller.uniforms.u_spike_progress.value = progress; + controller.graph.visible = + controller.spike_elapsed_seconds > 0.1 && + controller.spike_elapsed_seconds < 1.9 && + Math.round(timestamp) % 2 === 0; + } + } + } + + function on_pointer_move(event) { + if (event.pointerType !== "mouse") { + leave(); + return; + } + controller.pointer_x = event.clientX; + controller.pointer_y = event.clientY; + controller.pointer_dirty = true; + } + + document.addEventListener("pointermove", on_pointer_move, { + capture: true, + passive: true, + signal: abort_controller.signal, + }); + document.addEventListener( + "pointerout", + (event) => { + if (event.relatedTarget === null) leave(); + }, + { capture: true, passive: true, signal: abort_controller.signal }, + ); + window.addEventListener("blur", leave, { signal: abort_controller.signal }); + document.addEventListener( + "visibilitychange", + () => { + controller.last_timestamp = 0; + if (document.hidden) leave(); + }, + { signal: abort_controller.signal }, + ); + + controller.animation_frame = requestAnimationFrame(animate); +} + +export function triggerSpike(terrain, sequence, x, y) { + const controller = controllers.get(terrain); + if (!controller || sequence === controller.last_spike_sequence) return; + controller.last_spike_sequence = sequence; + if (controller.reduced_motion?.matches) return; + + controller.spike_active = true; + controller.spike_elapsed_seconds = 0; + controller.uniforms.u_spike_active.value = 1; + controller.uniforms.u_spike.value.set(x, y); + controller.uniforms.u_spike_progress.value = 0; + controller.graph.position.set(x + 1, 0.5, y - 1); +} + +export function uninstall(terrain) { + const controller = controllers.get(terrain); + if (!controller) return; + controller.abort_controller?.abort(); + if (controller.animation_frame) cancelAnimationFrame(controller.animation_frame); + controllers.delete(terrain); +} diff --git a/src/web_page/terrain.gleam b/src/web_page/terrain.gleam new file mode 100644 index 0000000..1f56405 --- /dev/null +++ b/src/web_page/terrain.gleam @@ -0,0 +1,158 @@ +import gleam/dict.{type Dict} +import gleam/float +import gleam/int +import gleam/option.{type Option} +import gleam/result +import lustre/attribute.{type Attribute} +import lustre/effect +import lustre/element +import savoiardi/camera.{type Camera} +import savoiardi/object.{type Object3D} +import savoiardi/renderer as three_renderer +import tiramisu/dev/extension +import tiramisu/dev/runtime.{type Runtime} + +pub const tag: String = "tiramisu-terrain" + +pub fn terrain( + id: String, + attributes: List(Attribute(msg)), +) -> element.Element(msg) { + element.element(tag, [attribute.id(id), ..attributes], []) +} + +pub fn seed(value: Int) -> Attribute(msg) { + attribute.attribute("seed", int.to_string(value)) +} + +pub fn spike(sequence: Int, x: Float, y: Float) -> List(Attribute(msg)) { + [ + attribute.attribute("spike-sequence", int.to_string(sequence)), + attribute.attribute("spike-x", float.to_string(x)), + attribute.attribute("spike-y", float.to_string(y)), + ] +} + +fn create( + runtime: Runtime, + id: String, + parent_id: String, + attributes: Dict(String, String), +) -> #(Runtime, effect.Effect(extension.Msg)) { + let object = create_object(get_int(attributes, "seed", 0)) + let next_runtime = runtime.add_object(runtime, id, object:, parent_id:, tag:) + + #(next_runtime, install_effect(runtime, object)) +} + +fn update( + runtime: Runtime, + _id: String, + _parent_id: String, + object: Option(Object3D), + attributes: Dict(String, String), + changed_attributes: extension.AttributeChanges, +) -> #(Runtime, effect.Effect(extension.Msg)) { + case object, extension.has_change(changed_attributes, "spike-sequence") { + option.Some(object), True -> { + let sequence = get_int(attributes, "spike-sequence", 0) + let x = get_float(attributes, "spike-x", 0.0) + let y = get_float(attributes, "spike-y", 0.0) + #(runtime, spike_effect(object, sequence, x, y)) + } + _, _ -> #(runtime, effect.none()) + } +} + +fn remove( + runtime: Runtime, + id: String, + parent_id: String, + object: Object3D, +) -> #(Runtime, effect.Effect(extension.Msg)) { + let next_runtime = runtime.remove_object(runtime, id, parent_id, object) + #(next_runtime, uninstall_effect(object)) +} + +fn install_effect( + runtime: Runtime, + object: Object3D, +) -> effect.Effect(extension.Msg) { + case runtime.active_camera(runtime) { + Ok(camera) -> + effect.from(fn(_) { + install( + object, + three_renderer.canvas(runtime.threejs_renderer(runtime)), + camera, + ) + Nil + }) + Error(Nil) -> effect.none() + } +} + +fn spike_effect( + object: Object3D, + sequence: Int, + x: Float, + y: Float, +) -> effect.Effect(extension.Msg) { + effect.from(fn(_) { + trigger_spike(object, sequence, x, y) + Nil + }) +} + +fn uninstall_effect(object: Object3D) -> effect.Effect(extension.Msg) { + effect.from(fn(_) { + uninstall(object) + Nil + }) +} + +fn get_int(attributes: Dict(String, String), key: String, default: Int) -> Int { + dict.get(attributes, key) + |> result.try(int.parse) + |> result.unwrap(default) +} + +fn get_float( + attributes: Dict(String, String), + key: String, + default: Float, +) -> Float { + dict.get(attributes, key) + |> result.try(float.parse) + |> result.unwrap(default) +} + +fn observed_attributes() -> List(String) { + ["spike-sequence", "spike-x", "spike-y"] +} + +pub fn ext() -> extension.Extension { + extension.node_extension( + tag:, + observed_attributes: observed_attributes(), + create:, + update:, + remove:, + ) +} + +@external(javascript, "./terrain.ffi.mjs", "createTerrain") +fn create_object(seed: Int) -> Object3D + +@external(javascript, "./terrain.ffi.mjs", "install") +fn install( + object: Object3D, + canvas: three_renderer.Canvas, + camera: Camera, +) -> Nil + +@external(javascript, "./terrain.ffi.mjs", "triggerSpike") +fn trigger_spike(object: Object3D, sequence: Int, x: Float, y: Float) -> Nil + +@external(javascript, "./terrain.ffi.mjs", "uninstall") +fn uninstall(object: Object3D) -> Nil