Compare commits
merge into: ndepoel:master
ndepoel:2019
ndepoel:cas
ndepoel:framework
ndepoel:fsr3.1.1
ndepoel:fsr3.1.4
ndepoel:fsr3framegen
ndepoel:master
ndepoel:srp
pull from: ndepoel:framework
ndepoel:2019
ndepoel:cas
ndepoel:framework
ndepoel:fsr3.1.1
ndepoel:fsr3.1.4
ndepoel:fsr3framegen
ndepoel:master
ndepoel:srp
24 Commits
42 changed files with 1077 additions and 1094 deletions
-
57Runtime/Common/FfxContextBase.cs
-
11Runtime/Common/FfxContextBase.cs.meta
-
79Runtime/Common/FfxPassBase.cs
-
11Runtime/Common/FfxPassBase.cs.meta
-
96Runtime/Common/FfxResourcesBase.cs
-
11Runtime/Common/FfxResourcesBase.cs.meta
-
46Runtime/Common/FfxSpd.cs
-
11Runtime/Common/FfxSpd.cs.meta
-
118Runtime/Common/FfxUtils.cs
-
11Runtime/Common/FfxUtils.cs.meta
-
90Runtime/FSR2/Fsr2.cs
-
265Runtime/FSR2/Fsr2Context.cs
-
259Runtime/FSR2/Fsr2Pass.cs
-
89Runtime/FSR2/Fsr2Resources.cs
-
90Runtime/FSR3/Fsr3Upscaler.cs
-
1Runtime/FSR3/Fsr3UpscalerAssets.cs
-
295Runtime/FSR3/Fsr3UpscalerContext.cs
-
322Runtime/FSR3/Fsr3UpscalerPass.cs
-
93Runtime/FSR3/Fsr3UpscalerResources.cs
-
4Shaders/ffx_fsr2_accumulate_pass.compute
-
4Shaders/ffx_fsr2_autogen_reactive_pass.compute
-
4Shaders/ffx_fsr2_compute_luminance_pyramid_pass.compute
-
4Shaders/ffx_fsr2_depth_clip_pass.compute
-
4Shaders/ffx_fsr2_lock_pass.compute
-
4Shaders/ffx_fsr2_rcas_pass.compute
-
4Shaders/ffx_fsr2_reconstruct_previous_depth_pass.compute
-
4Shaders/ffx_fsr2_tcr_autogen_pass.compute
-
4Shaders/ffx_fsr3upscaler_accumulate_pass.compute
-
4Shaders/ffx_fsr3upscaler_autogen_reactive_pass.compute
-
4Shaders/ffx_fsr3upscaler_debug_view_pass.compute
-
4Shaders/ffx_fsr3upscaler_luma_instability_pass.compute
-
4Shaders/ffx_fsr3upscaler_luma_pyramid_pass.compute
-
4Shaders/ffx_fsr3upscaler_prepare_inputs_pass.compute
-
4Shaders/ffx_fsr3upscaler_prepare_reactivity_pass.compute
-
4Shaders/ffx_fsr3upscaler_rcas_pass.compute
-
4Shaders/ffx_fsr3upscaler_shading_change_pass.compute
-
4Shaders/ffx_fsr3upscaler_shading_change_pyramid_pass.compute
-
4Shaders/ffx_fsr3upscaler_tcr_autogen_pass.compute
-
12Shaders/ffx_unity_common.cginc
-
0Shaders/ffx_unity_common.cginc.meta
-
48Shaders/shaders/fsr2/ffx_fsr2_callbacks_hlsl.h
-
60Shaders/shaders/fsr3upscaler/ffx_fsr3upscaler_callbacks_hlsl.h
@ -0,0 +1,57 @@ |
|||||
|
using System; |
||||
|
using System.Runtime.InteropServices; |
||||
|
using UnityEngine; |
||||
|
using UnityEngine.Rendering; |
||||
|
|
||||
|
namespace FidelityFX |
||||
|
{ |
||||
|
public abstract class FfxContextBase |
||||
|
{ |
||||
|
protected static void DestroyPass<TPass>(ref TPass pass) |
||||
|
where TPass: class, IDisposable |
||||
|
{ |
||||
|
if (pass == null) |
||||
|
return; |
||||
|
|
||||
|
pass.Dispose(); |
||||
|
pass = null; |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Convenience class for handling a constants buffer containing a single struct item.
|
||||
|
/// This wraps the compute buffer and the value array, as well as providing easy access to both.
|
||||
|
/// </summary>
|
||||
|
protected class ConstantsBuffer<TConst> |
||||
|
where TConst: struct |
||||
|
{ |
||||
|
private ComputeBuffer _computeBuffer; |
||||
|
|
||||
|
private readonly TConst[] _constArray = { new TConst() }; |
||||
|
public ref TConst Value => ref _constArray[0]; |
||||
|
|
||||
|
public void Create() |
||||
|
{ |
||||
|
_computeBuffer = new ComputeBuffer(1, Marshal.SizeOf<TConst>(), ComputeBufferType.Constant); |
||||
|
} |
||||
|
|
||||
|
public void UpdateBufferData(CommandBuffer commandBuffer) |
||||
|
{ |
||||
|
commandBuffer.SetBufferData(_computeBuffer, _constArray); |
||||
|
} |
||||
|
|
||||
|
public void Destroy() |
||||
|
{ |
||||
|
if (_computeBuffer == null) |
||||
|
return; |
||||
|
|
||||
|
_computeBuffer.Release(); |
||||
|
_computeBuffer = null; |
||||
|
} |
||||
|
|
||||
|
public static implicit operator ComputeBuffer(ConstantsBuffer<TConst> constants) |
||||
|
{ |
||||
|
return constants._computeBuffer; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,11 @@ |
|||||
|
fileFormatVersion: 2 |
||||
|
guid: f4aeccbeb6e61434eb2e50b7190fda8d |
||||
|
MonoImporter: |
||||
|
externalObjects: {} |
||||
|
serializedVersion: 2 |
||||
|
defaultReferences: [] |
||||
|
executionOrder: 0 |
||||
|
icon: {instanceID: 0} |
||||
|
userData: |
||||
|
assetBundleName: |
||||
|
assetBundleVariant: |
||||
@ -0,0 +1,79 @@ |
|||||
|
using System; |
||||
|
using UnityEngine; |
||||
|
using UnityEngine.Profiling; |
||||
|
using UnityEngine.Rendering; |
||||
|
|
||||
|
namespace FidelityFX |
||||
|
{ |
||||
|
internal abstract class FfxPassBase: IDisposable |
||||
|
{ |
||||
|
private readonly string _techName; |
||||
|
private string _passName; |
||||
|
|
||||
|
protected ComputeShader ComputeShader; |
||||
|
protected int KernelIndex; |
||||
|
|
||||
|
protected FfxPassBase(string techName) |
||||
|
{ |
||||
|
_techName = techName; |
||||
|
} |
||||
|
|
||||
|
protected virtual void InitComputeShader(string passName, ComputeShader shader, string kernelName = "CS") |
||||
|
{ |
||||
|
if (shader == null) |
||||
|
{ |
||||
|
throw new MissingReferenceException($"Shader for {_techName} pass '{passName}' could not be loaded! Please ensure it is included in the project correctly."); |
||||
|
} |
||||
|
|
||||
|
_passName = passName; |
||||
|
ComputeShader = shader; |
||||
|
KernelIndex = ComputeShader.FindKernel(kernelName); |
||||
|
} |
||||
|
|
||||
|
public virtual void Dispose() |
||||
|
{ |
||||
|
} |
||||
|
|
||||
|
protected ProfilerSampler ProfilerSample(CommandBuffer commandBuffer) |
||||
|
{ |
||||
|
return new ProfilerSampler(_passName, commandBuffer); |
||||
|
} |
||||
|
|
||||
|
protected readonly struct ProfilerSampler : IDisposable |
||||
|
{ |
||||
|
private readonly string _name; |
||||
|
private readonly CommandBuffer _commandBuffer; |
||||
|
|
||||
|
public ProfilerSampler(string name, CommandBuffer commandBuffer) |
||||
|
{ |
||||
|
_name = name; |
||||
|
_commandBuffer = commandBuffer; |
||||
|
_commandBuffer.BeginSample(_name); |
||||
|
} |
||||
|
|
||||
|
public void Dispose() |
||||
|
{ |
||||
|
_commandBuffer.EndSample(_name); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
internal abstract class FfxPassWithFlags<TFlags> : FfxPassBase |
||||
|
where TFlags: Enum |
||||
|
{ |
||||
|
protected readonly TFlags Flags; |
||||
|
|
||||
|
protected FfxPassWithFlags(string techName, TFlags flags) : base(techName) |
||||
|
{ |
||||
|
Flags = flags; |
||||
|
} |
||||
|
|
||||
|
protected override void InitComputeShader(string passName, ComputeShader shader, string kernelName = "CS") |
||||
|
{ |
||||
|
base.InitComputeShader(passName, shader, kernelName); |
||||
|
SetupShaderKeywords(); |
||||
|
} |
||||
|
|
||||
|
protected abstract void SetupShaderKeywords(); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,11 @@ |
|||||
|
fileFormatVersion: 2 |
||||
|
guid: deac9cc5a4c6df64db05956030e79425 |
||||
|
MonoImporter: |
||||
|
externalObjects: {} |
||||
|
serializedVersion: 2 |
||||
|
defaultReferences: [] |
||||
|
executionOrder: 0 |
||||
|
icon: {instanceID: 0} |
||||
|
userData: |
||||
|
assetBundleName: |
||||
|
assetBundleVariant: |
||||
@ -0,0 +1,96 @@ |
|||||
|
using System; |
||||
|
using System.Runtime.InteropServices; |
||||
|
using UnityEngine; |
||||
|
using UnityEngine.Experimental.Rendering; |
||||
|
|
||||
|
namespace FidelityFX |
||||
|
{ |
||||
|
internal abstract class FfxResourcesBase |
||||
|
{ |
||||
|
protected static ComputeBuffer CreateBuffer<TElem>(string name, int count) |
||||
|
{ |
||||
|
return new ComputeBuffer(count, Marshal.SizeOf<TElem>()); |
||||
|
} |
||||
|
|
||||
|
protected static Texture2D CreateLookup(string name, GraphicsFormat format, Color data) |
||||
|
{ |
||||
|
var tex = new Texture2D(1, 1, format, TextureCreationFlags.None) { name = name }; |
||||
|
tex.SetPixel(0, 0, data); |
||||
|
tex.Apply(); |
||||
|
return tex; |
||||
|
} |
||||
|
|
||||
|
protected static Texture2D CreateLookup<T>(string name, in Vector2Int size, GraphicsFormat format, T[] data) |
||||
|
{ |
||||
|
var tex = new Texture2D(size.x, size.y, format, TextureCreationFlags.None) { name = name }; |
||||
|
tex.SetPixelData(data, 0); |
||||
|
tex.Apply(); |
||||
|
return tex; |
||||
|
} |
||||
|
|
||||
|
protected static RenderTexture CreateResource(string name, in Vector2Int size, GraphicsFormat format) |
||||
|
{ |
||||
|
var rt = new RenderTexture(size.x, size.y, 0, format) { name = name, enableRandomWrite = true }; |
||||
|
rt.Create(); |
||||
|
return rt; |
||||
|
} |
||||
|
|
||||
|
protected static RenderTexture CreateResourceMips(string name, in Vector2Int size, GraphicsFormat format) |
||||
|
{ |
||||
|
int mipCount = 1 + Mathf.FloorToInt(Mathf.Log(Math.Max(size.x, size.y), 2.0f)); |
||||
|
var rt = new RenderTexture(size.x, size.y, 0, format, mipCount) { name = name, enableRandomWrite = true, useMipMap = true, autoGenerateMips = false }; |
||||
|
rt.Create(); |
||||
|
return rt; |
||||
|
} |
||||
|
|
||||
|
protected static void CreateDoubleBufferedResource(RenderTexture[] resource, string name, in Vector2Int size, GraphicsFormat format, int numElements = 2) |
||||
|
{ |
||||
|
numElements = Math.Min(resource.Length, numElements); |
||||
|
for (int i = 0; i < numElements; ++i) |
||||
|
{ |
||||
|
resource[i] = new RenderTexture(size.x, size.y, 0, format) { name = name + (i + 1), enableRandomWrite = true }; |
||||
|
resource[i].Create(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
protected static void DestroyResource(ref Texture2D resource) |
||||
|
{ |
||||
|
if (resource == null) |
||||
|
return; |
||||
|
|
||||
|
#if UNITY_EDITOR
|
||||
|
if (Application.isPlaying && !UnityEditor.EditorApplication.isPaused) |
||||
|
UnityEngine.Object.Destroy(resource); |
||||
|
else |
||||
|
UnityEngine.Object.DestroyImmediate(resource); |
||||
|
#else
|
||||
|
UnityEngine.Object.Destroy(resource); |
||||
|
#endif
|
||||
|
resource = null; |
||||
|
} |
||||
|
|
||||
|
protected static void DestroyResource(ref RenderTexture resource) |
||||
|
{ |
||||
|
if (resource == null) |
||||
|
return; |
||||
|
|
||||
|
resource.Release(); |
||||
|
resource = null; |
||||
|
} |
||||
|
|
||||
|
protected static void DestroyResource(ref ComputeBuffer resource) |
||||
|
{ |
||||
|
if (resource == null) |
||||
|
return; |
||||
|
|
||||
|
resource.Release(); |
||||
|
resource = null; |
||||
|
} |
||||
|
|
||||
|
protected static void DestroyResource(RenderTexture[] resource) |
||||
|
{ |
||||
|
for (int i = 0; i < resource.Length; ++i) |
||||
|
DestroyResource(ref resource[i]); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,11 @@ |
|||||
|
fileFormatVersion: 2 |
||||
|
guid: 2a46b97ac72c45545a80a896462e1112 |
||||
|
MonoImporter: |
||||
|
externalObjects: {} |
||||
|
serializedVersion: 2 |
||||
|
defaultReferences: [] |
||||
|
executionOrder: 0 |
||||
|
icon: {instanceID: 0} |
||||
|
userData: |
||||
|
assetBundleName: |
||||
|
assetBundleVariant: |
||||
@ -0,0 +1,46 @@ |
|||||
|
using System; |
||||
|
using System.Runtime.InteropServices; |
||||
|
using UnityEngine; |
||||
|
|
||||
|
namespace FidelityFX |
||||
|
{ |
||||
|
public static class FfxSpd |
||||
|
{ |
||||
|
public static void SetupSpdConstants(Vector2Int resolution, ref SpdConstants spdConstants, out Vector2Int dispatchThreadGroupCount, int mips = -1) |
||||
|
{ |
||||
|
RectInt rectInfo = new RectInt(0, 0, resolution.x, resolution.y); |
||||
|
SpdSetup(rectInfo, out dispatchThreadGroupCount, out Vector2Int workGroupOffset, out Vector2Int numWorkGroupsAndMips, mips); |
||||
|
|
||||
|
spdConstants.numWorkGroups = (uint)numWorkGroupsAndMips.x; |
||||
|
spdConstants.mips = (uint)numWorkGroupsAndMips.y; |
||||
|
spdConstants.workGroupOffsetX = (uint)workGroupOffset.x; |
||||
|
spdConstants.workGroupOffsetY = (uint)workGroupOffset.y; |
||||
|
} |
||||
|
|
||||
|
public static void SpdSetup(RectInt rectInfo, out Vector2Int dispatchThreadGroupCount, out Vector2Int workGroupOffset, out Vector2Int numWorkGroupsAndMips, int mips = -1) |
||||
|
{ |
||||
|
workGroupOffset = new Vector2Int(rectInfo.x / 64, rectInfo.y / 64); |
||||
|
|
||||
|
int endIndexX = (rectInfo.x + rectInfo.width - 1) / 64; |
||||
|
int endIndexY = (rectInfo.y + rectInfo.height - 1) / 64; |
||||
|
|
||||
|
dispatchThreadGroupCount = new Vector2Int(endIndexX + 1 - workGroupOffset.x, endIndexY + 1 - workGroupOffset.y); |
||||
|
|
||||
|
numWorkGroupsAndMips = new Vector2Int(dispatchThreadGroupCount.x * dispatchThreadGroupCount.y, mips); |
||||
|
if (mips < 0) |
||||
|
{ |
||||
|
float resolution = Math.Max(rectInfo.width, rectInfo.height); |
||||
|
numWorkGroupsAndMips.y = Math.Min(Mathf.FloorToInt(Mathf.Log(resolution, 2.0f)), 12); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
[Serializable, StructLayout(LayoutKind.Sequential)] |
||||
|
public struct SpdConstants |
||||
|
{ |
||||
|
public uint mips; |
||||
|
public uint numWorkGroups; |
||||
|
public uint workGroupOffsetX; |
||||
|
public uint workGroupOffsetY; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,11 @@ |
|||||
|
fileFormatVersion: 2 |
||||
|
guid: aee0f42dc1676ab4db5c01520b0b925a |
||||
|
MonoImporter: |
||||
|
externalObjects: {} |
||||
|
serializedVersion: 2 |
||||
|
defaultReferences: [] |
||||
|
executionOrder: 0 |
||||
|
icon: {instanceID: 0} |
||||
|
userData: |
||||
|
assetBundleName: |
||||
|
assetBundleVariant: |
||||
@ -0,0 +1,118 @@ |
|||||
|
using System; |
||||
|
using System.Runtime.InteropServices; |
||||
|
using UnityEngine; |
||||
|
using UnityEngine.Rendering; |
||||
|
|
||||
|
namespace FidelityFX |
||||
|
{ |
||||
|
public static class FfxUtils |
||||
|
{ |
||||
|
public static float GetMipmapBiasOffset(int renderWidth, int displayWidth) |
||||
|
{ |
||||
|
return Mathf.Log((float)renderWidth / displayWidth, 2.0f) - 1.0f; |
||||
|
} |
||||
|
|
||||
|
public static int GetJitterPhaseCount(int renderWidth, int displayWidth) |
||||
|
{ |
||||
|
const float basePhaseCount = 8.0f; |
||||
|
int jitterPhaseCount = (int)(basePhaseCount * Mathf.Pow((float)displayWidth / renderWidth, 2.0f)); |
||||
|
return jitterPhaseCount; |
||||
|
} |
||||
|
|
||||
|
public static void GetJitterOffset(out float outX, out float outY, int index, int phaseCount) |
||||
|
{ |
||||
|
outX = Halton((index % phaseCount) + 1, 2) - 0.5f; |
||||
|
outY = Halton((index % phaseCount) + 1, 3) - 0.5f; |
||||
|
} |
||||
|
|
||||
|
// Calculate halton number for index and base.
|
||||
|
private static float Halton(int index, int @base) |
||||
|
{ |
||||
|
float f = 1.0f, result = 0.0f; |
||||
|
|
||||
|
for (int currentIndex = index; currentIndex > 0;) { |
||||
|
|
||||
|
f /= @base; |
||||
|
result += f * (currentIndex % @base); |
||||
|
currentIndex = (int)Mathf.Floor((float)currentIndex / @base); |
||||
|
} |
||||
|
|
||||
|
return result; |
||||
|
} |
||||
|
|
||||
|
public static float Lanczos2(float value) |
||||
|
{ |
||||
|
return Mathf.Abs(value) < Mathf.Epsilon ? 1.0f : Mathf.Sin(Mathf.PI * value) / (Mathf.PI * value) * (Mathf.Sin(0.5f * Mathf.PI * value) / (0.5f * Mathf.PI * value)); |
||||
|
} |
||||
|
|
||||
|
public static float[] GenerateLanczos2Table(int width) |
||||
|
{ |
||||
|
float[] lanczos2Weights = new float[width]; |
||||
|
for (int currentLanczosWidthIndex = 0; currentLanczosWidthIndex < width; ++currentLanczosWidthIndex) |
||||
|
{ |
||||
|
float x = 2.0f * currentLanczosWidthIndex / (width - 1); |
||||
|
float y = Lanczos2(x); |
||||
|
lanczos2Weights[currentLanczosWidthIndex] = y; |
||||
|
} |
||||
|
|
||||
|
return lanczos2Weights; |
||||
|
} |
||||
|
|
||||
|
public static Vector4 SetupDeviceDepthToViewSpaceDepthParams(Vector2Int renderSize, float cameraNear, float cameraFar, float cameraFovAngleVertical, bool inverted, bool infinite) |
||||
|
{ |
||||
|
// make sure it has no impact if near and far plane values are swapped in dispatch params
|
||||
|
// the flags "inverted" and "infinite" will decide what transform to use
|
||||
|
float min = Mathf.Min(cameraNear, cameraFar); |
||||
|
float max = Mathf.Max(cameraNear, cameraFar); |
||||
|
|
||||
|
if (inverted) |
||||
|
{ |
||||
|
(min, max) = (max, min); |
||||
|
} |
||||
|
|
||||
|
float q = max / (min - max); |
||||
|
float d = -1.0f; |
||||
|
|
||||
|
Vector4 matrixElemC = new Vector4(q, -1.0f - Mathf.Epsilon, q, 0.0f + Mathf.Epsilon); |
||||
|
Vector4 matrixElemE = new Vector4(q * min, -min - Mathf.Epsilon, q * min, max); |
||||
|
|
||||
|
// Revert x and y coords
|
||||
|
float aspect = (float)renderSize.x / renderSize.y; |
||||
|
float cotHalfFovY = Mathf.Cos(0.5f * cameraFovAngleVertical) / Mathf.Sin(0.5f * cameraFovAngleVertical); |
||||
|
|
||||
|
int matrixIndex = (inverted ? 2 : 0) + (infinite ? 1 : 0); |
||||
|
return new Vector4( |
||||
|
d * matrixElemC[matrixIndex], |
||||
|
matrixElemE[matrixIndex], |
||||
|
aspect / cotHalfFovY, |
||||
|
1.0f / cotHalfFovY); |
||||
|
} |
||||
|
|
||||
|
#if !UNITY_2021_1_OR_NEWER
|
||||
|
internal static void SetBufferData(this CommandBuffer commandBuffer, ComputeBuffer computeBuffer, Array data) |
||||
|
{ |
||||
|
commandBuffer.SetComputeBufferData(computeBuffer, data); |
||||
|
} |
||||
|
#endif
|
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Alternative for CommandBuffer.SetComputeTextureParam that guards against attempts to bind mip levels that don't exist.
|
||||
|
/// </summary>
|
||||
|
internal static void SetComputeTextureMipParam(this CommandBuffer commandBuffer, ComputeShader computeShader, int kernelIndex, int nameID, Texture texture, int mipLevel) |
||||
|
{ |
||||
|
mipLevel = Math.Min(mipLevel, texture.mipmapCount - 1); |
||||
|
commandBuffer.SetComputeTextureParam(computeShader, kernelIndex, nameID, texture, mipLevel); |
||||
|
} |
||||
|
|
||||
|
internal static void SetComputeResourceParam(this CommandBuffer commandBuffer, ComputeShader computeShader, int kernelIndex, int nameID, in ResourceView resource) |
||||
|
{ |
||||
|
commandBuffer.SetComputeTextureParam(computeShader, kernelIndex, nameID, resource.RenderTarget, resource.MipLevel, resource.SubElement); |
||||
|
} |
||||
|
|
||||
|
internal static void SetComputeConstantBufferParam<TBuf>(this CommandBuffer commandBuffer, ComputeShader computeShader, int nameID, ComputeBuffer buffer) |
||||
|
where TBuf: struct |
||||
|
{ |
||||
|
commandBuffer.SetComputeConstantBufferParam(computeShader, nameID, buffer, 0, Marshal.SizeOf<TBuf>()); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,11 @@ |
|||||
|
fileFormatVersion: 2 |
||||
|
guid: 9c5c0f0b987cca64c8dfd8051e09962e |
||||
|
MonoImporter: |
||||
|
externalObjects: {} |
||||
|
serializedVersion: 2 |
||||
|
defaultReferences: [] |
||||
|
executionOrder: 0 |
||||
|
icon: {instanceID: 0} |
||||
|
userData: |
||||
|
assetBundleName: |
||||
|
assetBundleVariant: |
||||
Write
Preview
Loading…
Cancel
Save
Reference in new issue