MyGit

abisxir/alasgar

Fork: 2 Star: 87 (更新于 2024-10-29 11:46:10)

license: MIT

Language: Nim .

3D game engine for nim

GitHub网址

alasgar

alasgar is a pure nim game engine based on OpenGL. The main reason to start developing a new game engine, was to learn graphics programming (first challenge) using nim programming language (second challenge). You can write the whole game logic and also shaders in nim. It supports a few platforms including mobile, web, and desktop. It performs well in the performance tests. It is the journey of a backend/system developer through graphics/game programming.

Platforms

  • Linux
  • Windows
  • Android
  • WebAssembly
  • Mac (work in progress)
  • iOS (not supoorted)

Experimental game engine

alasgar is a basic game engine, and it is limited, so it is not ready for production use.

Installation

nimble install alasgar

or simply the latest version:

nimble install https://github.com/abisxir/alasgar

Quick start

git clone https://github.com/abisxir/alasgar.git
cd alasgar/examples
nim c -r hello.nim

Table of Contents

Window and scene creation

import alasgar

# Creates a window named Step1
window("Step1", 830, 415)
   
let 
    # Creates a new scene
    scene = newScene()
    # Creates camera entity
    cameraEntity = newEntity(scene, "Camera")

# Sets camera position
cameraEntity.transform.position = vec3(5, 5, 5)
# Adds a perspective camera component to entity
add(
    cameraEntity, 
    newPerspectiveCamera(
        75, 
        runtime.ratio, 
        0.1, 
        100.0, 
        vec3(0) - cameraEntity.transform.position
    )
)
# Makes the camera entity child of the scene
add(scene, cameraEntity)

As you see, we instantiate a scene, add a camera to that, and render the created scene. If everything goes right, you will see an empty window with the given size. Run it using the nim compiler:

nim c -r main.nim

Check the example here.

When you create a window by default it runs in window mode, you can easily enable fullscreen mode:

# Creates a window named Hello and enables fullscreen mode.
window("Hello", 960, 540, fullscreen=true)

Screen size

By default the screen size is equal with window size, but maybe you like to have a lower resolution:

import alasgar

# Creates a window named Hello, and sets screen size to (160, 90)
screen(160, 90)
window("Hello", 640, 360)

...   

You need to specify it before creating window, after window creation there is no effect when setting the screen size.

Let us add a cube to our scene, but to see the cube, it is better if we give a brighter background to our window, it will make it easier to see our meshes before we add lights. So what we do we change scene background and add a cube mesh to our scene:

First mesh

...
let 
    # Creates a new scene
    scene = newScene()

# Sets background color
scene.background = parseHex("d7d1bf")
...
# Creates cube entity, by default position is 0, 0, 0
let cubeEntity = newEntity(scene, "Cube")
# Add a cube mesh component to entity
add(cubeEntity, newCubeMesh())
# Makes the cube enity child of the scene
add(scene, cubeEntity)
# Scale it up
cubeEntity.transform.scale = vec3(2)
...

That was all you need to do. If you compile and execute it, you will see an ugly black cube in a window with a slightly better color. As you guess we need to have a light in our scene to shade our cube. Let us add a point light to our scene:

Point light

...
# Creates the light entity
let lightEntity = newEntity(scene, "Light")
# Sets the light position
lightEntity.transform.position = vec3(4, 5, 4)
# Adds athe point light component to entity
add(
    lightEntity, 
    newPointLightComponent()
)
# Makes the light entity child of the scene
add(scene, lightEntity)
...

That is all we needed, now our cube maybe is less ugly now. Lights have some properties, like color, luminance, etc. You change it and you will shade the cube differently.

Scripts

To program an entity, we need to add a ScriptComponent to it. Each component has an access to its entity, the entity's transform and other components added to the entity. We can add a script to any entity using the "program" function or directly by instantiating a ScriptComponent using the "newScriptComponent" function.

...
# Adds a script component to the cube entity
program(cubeEntity, proc(script: ScriptComponent) =
    let t = 2 * runtime.age
    # Rotates the cube using euler angles
    script.transform.euler = vec3(
        sin(t),
        cos(t),
        sin(t) * cos(t),
    )
)
# Makes the cube enity child of the scene
add(scene, cubeEntity)
...

See now our cube rotates. As you see in the source code, we used an anonymous function to program the entity. You can define a function and use that here. Feel free to play with nim features. As you notice, we directly access transform component from script component. Each entity has a reference to the transform component, and all the components have a reference to their entity. We used Euler angles to change rotation. But also 'rotation' as a quat is available in TransformComponent and you can use it if you are looking for troubles:

type
    TransformComponent = ref object of RootObj
        position: Vec3              # position in local space
        scale: Vec3                 # scale in local space
        rotation: Quat              # rotation in local space
        euler: Vec3                 # write only euler angles in local space
        globalPosition: Vec3        # position in global space
        globalScale: Vec3           # scale in global space
        globalRotation: Quat        # rotation in global space
        parent: TransformComponent  # parent transform, read only

proc lookAt*(t: TransformComponent, target: Vec3)
proc lookAt*(t: TransformComponent, target: TransformComponent)

In the script we used runtime variable, it is a readonly property that gives us some good information about engine, also has a reference to the running engine's instance:

type 
    Runtime = object
        engine: Engine   # engine instance
        age: float32     # total seconds engine is running
        frames: int      # total frames rendered
        fps: float32     # current fps
        delta: float32   # delta between last two frames
        input: Input     # last input state
        ratio: float32   # screen ratio
        windowSize: Vec2 # window size
        screenSize: Vec2 # screen size

Material

We can change the cube color using material components. So what we need is to add a material component to define the cube's material. There is a sugar member in entity that can automatically define a material for entity when it is called, thanks to nim's templates and powerful property functions. I used the chroma library to manipulate colors, it is a great library, here you can see how to use it.

...
# Adds a material if it is not already added and sets the diffuse color
cubeEntity.material.diffuseColor = parseHtmlName("Tomato") 
# Also is possible to directly instantiate a component and add it to the cube
# In the case that you prefer to write more code :)
# addComponent(cubeEntity, newMaterialComponent(diffuseColor=parseHtmlName("Tomato")))
...

Material component contains the following properties:

type 
    MaterialComponent = ref object of Component
        diffuseColor: Color
        specularColor: Color
        emissiveColor: Color
        metallic: float32
        roughness: float32
        reflectance: float32
        ao: float32
        albedoMap: Texture
        normalMap: Texture
        metallicMap: Texture
        roughnessMap: Texture
        aoMap: Texture
        emissiveMap: Texture
        vframes: int
        hframes: int
        frame: int
        castShadow: bool

# Instantiate a material component
func newMaterialComponent(diffuseColor: Color=COLOR_WHITE, 
                          specularColor: Color=COLOR_WHITE, 
                          emissiveColor: Color=COLOR_BLACK,
                          albedoMap: Texture = nil, 
                          normalMap: Texture = nil, 
                          metallicMap: Texture = nil, 
                          roughnessMap: Texture = nil, 
                          aoMap: Texture = nil, 
                          emissiveMap: Texture = nil, 
                          metallic: float32 = 0.0,
                          roughness: float32 = 0.0,
                          reflectance: float32 = 0.0,
                          shininess: float32 = 128.0,
                          ao: float32 = 1.0,
                          frame: int=0,
                          vframes: int=1,
                          hframes: int=1,
                          castShadow: bool=false)

If roughness and metallic factors are zero also there is no metallic map and roughness map provided then the shader will use shininess and shades with phong model otherwise will be PBR. vfames, hframes, and frame is used to offset texture, very helpful for sprites or animations, will discuss it later in the sprites section.

Texture

It is time to give texture to our cube. To make it multi-platform you need to make "res" folder in your project root and copy all of your assets inside that folder with the desired heirarchy. Then the assets will be accessible using a relative path by res like "res://texture.png". It applies to all other assets like obj files, audio files or text files.

...
# Sets the diffuse color
cubeEntity.material.diffuseColor = parseHtmlName("White") 
# Sets albedo map
cubeEntity.material.albedoMap = newTexture("res://brickwall-albedo.jpg")
...

If you run the sample, you will see a textured cube which is not that much ugly this time but still there are a lot to improve.

The texture used here, grabbed from: https://publicdomaintextures.com/elementor-112/tiles08

More lights

As you see our scene has just one point light but there are other types of lights:

  • Direct lights
  • Spot lights
## Creates point lights
proc newPointLightComponent*(color: Color=COLOR_MILK, luminance=100.0): PointLightComponent
## Creates direct lights
proc newDirectLightComponent*(direction: Vec3, 
                              color: Color=COLOR_MILK, 
                              luminance: float32=100.0, 
                              shadow: bool=false,
                              shadowBias: float32=0.001): DirectLightComponent
## Creates spot point lights
proc newSpotPointLightComponent*(direction: Vec3,
                                 color: Color=COLOR_MILK, 
                                 luminance: float32=50.0,
                                 innerCutoff: float32=30, 
                                 outerCutoff: float32=45,
                                 shadow: bool=false,
                                 shadowBias: float32=0.001): SpotPointLightComponent

For now shadowing is just implemented for direct lights and spot point lights. Point lights are resource hungary and much heavy compared to direct lights, needs 6 times rendering the scene. Some tricks can applied but still heavy specially for webgl but will be implemented soon.

Access components

Let us program the point light's entity and access to the its component and just for fun change the light color and luminance. To access a component we can call get[T] on an entity or a component. Also, it is possible to access it using the index operator on any entity or component:

let c = get[MyComponent](e)

Or simply using an index operator:

let c = e[MyComponent]

If there is no such a component, it will return nil. Let us try it by adding a script component to our point light to program it:

...
# Adds a script component to the point light entity
program(lightEntity, proc(script: ScriptComponent) =
    let 
        t = runtime.age
        # Access to the point light component.
        light = script[PointLightComponent]
    # Or you can access it by calling getComponent function:
    # let light = get[PointLightComponent](script)
    # Changes light color
    light.color = color(
        abs(sin(t)), 
        1, 
        abs(cos(t))
    )
)
...

If you execute the last example, you will notice that the color is changing.

Interactive objects

There is a very simple implementation for interacting with objects in alasgar. As it always works with components, to interact with a game object we need to add an InteractiveComponent to our entity:

type
    InteractionHandleProc* = proc(component: InteractiveComponent, collision: Collision)
    OutHandleProc* = proc(component: InteractiveComponent)
    InteractiveComponent* = ref object of Component
        hover*: bool
        pressed*: bool
        pressStartTime*: float
        pressEndTime*: float
        input: Input
        onHover: InteractionHandleProc
        onOut: OutHandleProc
        onMotion: InteractionHandleProc
        onPress: InteractionHandleProc
        onRelease: InteractionHandleProc

So we can instantiate the component and with the hooks are available we can react to user interaction with objects. There is also some sugar functions to make our life easier:

proc `onHover=`*(e: Entity, f: InteractionHandleProc)
proc `onOut=`*(e: Entity, f: OutHandleProc) 
proc `onMotion=`*(e: Entity, f: InteractionHandleProc) 
proc `onPress=`*(e: Entity, f: InteractionHandleProc)
proc `onRelease=`*(e: Entity, f: InteractionHandleProc)

Let us define two functions and when user hovers on our cube change the emissive color:

...
# Handles mouse hover in
cubeEntity.onHover = proc(ic: InteractiveComponent, co: Collision) =
    ic[MaterialComponent].emissiveColor = color(0.6, 0.6, 0.0)
# Handles mouse hover out
cubeEntity.onOut = proc(ic: InteractiveComponent)=
    ic[MaterialComponent].emissiveColor = parseHtmlName("black")
# Adds a bounding box component to the cube entity, uses mesh bounds
addBoundingSphere(cubeEntity)
...

That is all, as you see, we have two functions to handle mouse's in and out events. To make interactive components working, you need to add a collision component. Alsgar supports just two types, AABB and sphere. Here we used helper functions to code less but like everywhere else you can add components using the core functions to have more control over it.

## Instantiate a collision component with bounding sphere using radius
proc newCollisionComponent*(radius: float32, offset: Vec3=VEC3_ZERO): CollisionComponent 
## Instantiate a collision component with box bounding using min and max
proc newCollisionComponent*(vMin, vMax: Vec3, offset: Vec3=VEC3_ZERO): CollisionComponent
## Adds a collision component with bounding box to entity uses mesh information when available
proc addBoundingBox*(e: Entity)
## Adds a collision component with bounding sphere to entity uses mesh information when available
proc addBoundingSphere*(e: Entity)

Effects

Effects can be attached to a camera and are nim functions which will be translated to GLSL functions on compile time so you can fix errors before running the application, thanks to shady. Let us add a snow effect to our camera. First we create a nim module named snow.nim:

# snow.nim
import alasgar

# Direct translation of https://www.shadertoy.com/view/4sX3z2 to nim
proc snowEffect*(CAMERA: Uniform[Camera],
                 FRAME: Uniform[Frame],
                 COLOR_CHANNEL: Layout[0, Uniform[Sampler2D]],
                 NORMAL_CHANNEL: Layout[1, Uniform[Sampler2D]],
                 DEPTH_CHANNEL: Layout[2, Uniform[Sampler2D]],
                 UV: Vec2,
                 COLOR: var Vec4) =
    var 
        fragCoord = UV * FRAME.RESOLUTION.xy
        snow = 0.0
        gradient = (1.0 - float(fragCoord.y / FRAME.RESOLUTION.x)) * 0.4
        random = fract(sin(dot(fragCoord.xy, vec2(12.9898,78.233))) * 43758.5453)
    for k in 0..5:
        for i in 0..11:
            var 
                cellSize = 2.0 + (float(i) * 3.0)
                downSpeed = 0.3 + (sin(FRAME.TIME * 0.4 + float(k + i * 20)) + 1.0) * 0.00008
                uv = (fragCoord.xy / FRAME.RESOLUTION.x) + vec2(0.01 * sin((FRAME.TIME + float(k * 6185)) * 0.6 + float(i)) * (5.0 / float(i)), downSpeed * (FRAME.TIME + float(k*1352)) * (1.0 / float(i)))
                uvStep = (ceil((uv) * cellSize - vec2(0.5,0.5)) / cellSize)
                x = fract(sin(dot(uvStep, vec2(12.9898 + float(k) * 12.0, 78.233 + float(k) * 315.156))) * 43758.5453 + float(k) * 12.0) - 0.5
                y = fract(sin(dot(uvStep, vec2(62.2364 + float(k) * 23.0, 94.674 + float(k) * 95.0))) * 62159.8432 + float(k) * 12.0) - 0.5
                randomMagnitude1 = sin(FRAME.TIME * 2.5) * 0.7 / cellSize
                randomMagnitude2 = cos(FRAME.TIME * 2.5) * 0.7 / cellSize
                d = 5.0 * distance((uvStep.xy + vec2(x * sin(y), y) * randomMagnitude1 + vec2(y,x) * randomMagnitude2), uv.xy)
                omiVal = fract(sin(dot(uvStep.xy, vec2(32.4691,94.615))) * 31572.1684)
            
            if omiVal < 0.08:
                let newd = (x+1.0)*0.4*clamp(1.9-d*(15.0+(x*6.3))*(cellSize/1.4),0.0,1.0)
                snow += newd

    COLOR = texture(COLOR_CHANNEL, UV) + vec4(snow) + gradient * vec4(0.4, 0.8, 1.0, 0.0) + random * 0.01

Now in main.nim, we import "snowEffect" and add it to our camera as an effect.

import alasgar
from snow import snowEffect
...
addEffect(cameraEntity[CameraComponent], "snowEffect", newCanvasShader(snowEffect))
...

That was all you need to do, if you run the code you will see our beautiful snow. As you see in function signature, there are some unused variables:

proc snowEffect*(CAMERA: Uniform[Camera],
                 FRAME: Uniform[Frame],
                 COLOR_CHANNEL: Layout[0, Uniform[Sampler2D]],
                 NORMAL_CHANNEL: Layout[1, Uniform[Sampler2D]],
                 DEPTH_CHANNEL: Layout[2, Uniform[Sampler2D]],
                 UV: Vec2,
                 COLOR: var Vec4)

These variables will be provided by engine and you can use them when it is required:

type
    Camera* = object
        POSITION*: Vec3
        VIEW_MATRIX*: Mat4
        INV_VIEW_MATRIX*: Mat4
        PROJECTION_MATRIX*: Mat4
        INV_PROJECTION_MATRIX*: Mat4
        EXPOSURE*: float
        GAMMA*: float
        NEAR*: float
        FAR*: float
    Frame* = object
        RESOLUTION*: Vec3
        TIME*: float
        TIME_DELTA*: float
        COUNT*: float
        MOUSE*: Vec4
        DATE*: Vec4

It is possible to provide uniform variables and textures if the shader's logic needs it. That is fairly easy, let us add a speed variable and control it from outside:

...
proc snowEffect*(FRAME: Uniform[Frame],
                 COLOR_CHANNEL: Layout[0, Uniform[Sampler2D]],
                 SPEED: Uniform[float],                 
                 UV: Vec2,
                 COLOR: var Vec4) =
    var 
        fragCoord = UV * FRAME.RESOLUTION.xy
        snow = 0.0
        gradient = (1.0 - float(fragCoord.y / FRAME.RESOLUTION.x)) * 0.4
        random = fract(sin(dot(fragCoord.xy, vec2(12.9898,78.233))) * 43758.5453)
    for k in 0..5:
        for i in 0..11:
            var 
                cellSize = 2.0 + (float(i) * 3.0)
                downSpeed = SPEED + (sin(FRAME.TIME * 0.4 + float(k + i * 20)) + 1.0) * 0.00008
...                

As you see, I removed the unused variables. Now we program our camera and change speed by time:

...
addEffect(cameraEntity[CameraComponent], "snowEffect", newCanvasShader(snowEffect))
program(cameraEntity, proc(script: ScriptComponent) =
    let 
        # Gets camera component
        camera = script[CameraComponent]
        # Gets effect shader
        effect = getEffect(camera, "snowEffect")
    # Updates new value in shader
    set(effect, "SPEED", 2.0)
)

That's it, now we have a shader param which we can update it based on our needs and alasgar will pass it to the shader when it is required. It was just the fragment shader that we programmed here, but also is possible to pass a function as vertex shader for some effects like camera shaking:

proc effectVertex*(CAMERA: Uniform[Camera],
                   FRAME: Uniform[Frame],
                   gl_VertexID: int,
                   UV: var Vec2,
                   gl_Position: var Vec4) =
    let v1: int = gl_VertexID and 1
    let v2: int = gl_VertexID and 2
    let x: float = float(v1 shl 2)
    let y: float = float(v2 shl 1)
    UV.x = x * 0.5
    UV.y = y * 0.5
    gl_Position = vec4(x - 1.0, y - 1.0, 0.0, 1.0)                   

This is the current implementation, depends on shader requirements you can change it and like the fragment shader, it accepts custom parameters. Then create a canvas shader with both vertex and fragmend shader code and add it as a new effect to the camera component:

addEffect(cameraEntity[CameraComponent], "myEffect", newCanvasShader(vertex, fragment))

There are also some predefined effects that you can use them:

# Creates bloom effect shader
proc newBloomEffect*(intersity: float32=1.0): Shader
# Creates FXAA effect shader 
proc newFxaaEffect*(spanMax=8'f32, 
                    reduceMul=1'f32 / 8'f32, 
                    reduceMin=1'f32 / 128'f32, 
                    split=0'f32): Shader
# Creates SSAO effect shader
proc newSSAOEffect*(samples=32'i32, 
                    sampleRadius=0.02'f32, 
                    intensity=1.0'f32, 
                    scale=2.5'f32, 
                    bias=0.05'f32, 
                    maxDistance=0.07'f32, 
                    noiseTexture: Texture)

There are also some functions to manipulate effects:

proc addEffect*(camera: CameraComponent, name: string, shader: Shader)
proc getEffect*(camera: CameraComponent, name: string): Shader
proc hasEffect*(camera: CameraComponent, name: string): bool
proc disableEffect*(camera: CameraComponent, name: string)
proc enableEffect*(camera: CameraComponent, name: string)
proc removeEffect*(camera: CameraComponent, name: string) = 

Shadows

Shadows are implemented only for spot and direct lights. Let us reset our example to a simpler scene to see how shadow works:

import alasgar

# Creates a window named Step10
window("Step10", 830, 415)
   
let 
    # Creates a new scene
    scene = newScene()
    # Creates the camera entity
    cameraEntity = newEntity(scene, "Camera")

# Sets the background color
scene.background = parseHex("d7d1bf")
# Sets fog desnity to enable fog, fancy effect :)
scene.fogDensity = 0.05

# Sets the camera position
cameraEntity.transform.position = 7.5 * vec3(1)
# Adds a perspective camera component to entity
add(
    cameraEntity, 
    newPerspectiveCamera(
        75, 
        runtime.ratio, 
        0.1, 
        100.0, 
        vec3(0) - cameraEntity.transform.position
    )
)
# Makes the camera entity child of the scene
add(scene, cameraEntity)

# Creates the cube entity, by default position is 0, 0, 0
let cubeEntity = newEntity(scene, "Cube")
# Add a cube mesh component to entity
add(cubeEntity, newCubeMesh())
# Adds a script component to the cube entity
program(cubeEntity, proc(script: ScriptComponent) =
    let t = 2 * runtime.age
    # Rotates the cube using euler angles
    script.transform.euler = vec3(
        sin(t),
        cos(t),
        sin(t) * cos(t),
    )
)
# Makes the cube enity child of the scene
add(scene, cubeEntity)
# Scale it up
cubeEntity.transform.scale = vec3(2)
# Enables shadows for the cube
cubeEntity.material.castShadow = true

# Creates the plane entity
let planeEntity = newEntity(scene, "Ground")
# Adds a plane mesh component to entity
add(planeEntity, newPlaneMesh(1, 1))
# Makes the plane entity child of the scene
add(scene, planeEntity)
# Sets the plane position and scale
planeEntity.transform.position = vec3(0, -3, 0)
planeEntity.transform.scale = vec3(200, 1, 200)
# Marks the plane to not cast shadows
planeEntity.material.castShadow = false

# Creates the light entity
let lightEntity = newEntity(scene, "Light")
# Adds a point light component to entity, with enabled shadow
add(
    lightEntity, 
    newDirectLightComponent(
        direction=vec3(0) - vec3(-4, 5, 4),
        shadow=true,
    )
)
# Makes the light entity child of the scene
add(scene, lightEntity)

# Renders an empty scene
render(scene)
# Runs game main loop
loop()

As you see, we enabled shadow casting for our cube using its material:

# Marks the plane to not cast shadows
cubeEntity.material.castShadow = true

And then, on the light we set the shadow flag to true:

# Adds a point light component to entity, with enabled shadow
add(
    lightEntity, 
    newDirectLightComponent(
        direction=vec3(0) - vec3(-4, 5, 4),
        shadow=true,
    )
)

That was everything needed to enable shadow for direct lights. The same apporach should happen to the spot lights:

## Creates a direct light component
proc newDirectLightComponent*(direction: Vec3, 
                              color: Color=COLOR_MILK, 
                              luminance: float32=100.0, 
                              shadow: bool=false,
                              shadowBias: float32=0.001): DirectLightComponent
## Creates a spot light component
proc newSpotPointLightComponent*(direction: Vec3,
                                 color: Color=COLOR_MILK, 
                                 luminance: float32=50.0,
                                 innerCutoff: float32=30, 
                                 outerCutoff: float32=45,
                                 shadow: bool=false,
                                 shadowBias: float32=0.001): SpotPointLightComponent

One extra line was also enabling fog on our scene:

# Sets the background color
scene.background = parseHex("d7d1bf")
# Sets fog desnity to enable fog, fancy effect :)
scene.fogDensity = 0.05

Fog automatically fades the scene to our background color. The scene object includes some other properties to change the environment effects:

type
    Scene* = ref object
        ## Background color, default is black
        background*: Color
        ## Ambient color which will affect all objects in the scene when lighting, default is black that means no effect
        ambient*: Color
        ## Fog density, default is 0
        fogDensity*: float32
        ## Minimum fog distance to apply fog effect, default is 0
        fogMinDistance*: float32
        environmentMap*: Texture
        environmentIntensity*: float32
        environmentBlurrity*: float32

Dependencies

vmath, chroma, and nimx

In the newer version of nim I had issue with compiling nim to GLSL using vmath, I removed it and provided a minimal implementation. Chroma is still a dependency. I also removed dependencies and also remaining build scripts that I borrow it from nimx. Some configs and sample projects will be provided to show how to import the application to android and webassembly.

最近版本更新:(数据更新于 1970-01-01 00:00:00)

abisxir/alasgar同语言 Nim最近更新仓库

2023-07-31 10:24:23 ringabout/httpx

1970-01-01 00:00:00 codex-storage/apatheia

1970-01-01 00:00:00 zer0-star/matsuri

1970-01-01 00:00:00 squattingmonk/nasher

1970-01-01 00:00:00 zedeus/nitter

1970-01-01 00:00:00 PrasannaVenkadesh/localsend-cli